Compare commits
4 Commits
a517988482
...
fb55c1d256
| Author | SHA1 | Date | |
|---|---|---|---|
| fb55c1d256 | |||
| 69fff64bfd | |||
| d794a3b6ea | |||
| 004b865e47 |
12
README.md
12
README.md
@ -140,8 +140,20 @@ SUPABASE_SERVICE_ROLE_KEY=
|
||||
|
||||
# Optional
|
||||
NEXT_PUBLIC_GOOGLE_CLIENT_ID= # For Calendar integration
|
||||
NEXT_PUBLIC_MISSION_CONTROL_URL=
|
||||
NEXT_PUBLIC_GANTT_BOARD_URL=
|
||||
NEXT_PUBLIC_BLOG_BACKUP_URL=
|
||||
NEXT_PUBLIC_GITEA_URL=
|
||||
NEXT_PUBLIC_GITHUB_URL=
|
||||
NEXT_PUBLIC_VERCEL_URL=
|
||||
NEXT_PUBLIC_SUPABASE_SITE_URL=
|
||||
NEXT_PUBLIC_GOOGLE_URL=
|
||||
NEXT_PUBLIC_GOOGLE_CALENDAR_URL=
|
||||
NEXT_PUBLIC_GOOGLE_CALENDAR_SETTINGS_URL=
|
||||
```
|
||||
|
||||
Website URLs are centralized in `lib/config/sites.ts` and can be overridden via the optional variables above.
|
||||
|
||||
## Getting Started
|
||||
|
||||
```bash
|
||||
|
||||
230
app/api/search/route.ts
Normal file
230
app/api/search/route.ts
Normal file
@ -0,0 +1,230 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createClient } from "@supabase/supabase-js";
|
||||
import {
|
||||
getGanttProjectUrl,
|
||||
getGanttSprintUrl,
|
||||
getGanttTaskUrl,
|
||||
getMissionControlDocumentsUrl,
|
||||
} from "@/lib/config/sites";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// ============================================================================
|
||||
// SEARCHABLE PROTOCOL - Minimal search result interface
|
||||
// ============================================================================
|
||||
|
||||
export type SearchableType = "task" | "project" | "document" | "sprint";
|
||||
|
||||
export interface SearchableResult {
|
||||
id: string;
|
||||
type: SearchableType;
|
||||
title: string;
|
||||
snippet?: string; // Brief preview text (max 150 chars)
|
||||
url: string; // Deep link to full view
|
||||
icon: string;
|
||||
status?: string; // For visual badges
|
||||
color?: string; // For project/task colors
|
||||
}
|
||||
|
||||
export interface SearchResponse {
|
||||
results: SearchableResult[];
|
||||
total: number;
|
||||
query: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SEARCHABLE ENTITY CONFIGURATION
|
||||
// Add new searchable types here following the pattern
|
||||
// ============================================================================
|
||||
|
||||
interface SearchableEntityConfig {
|
||||
table: string;
|
||||
type: SearchableType;
|
||||
titleField: string;
|
||||
snippetField?: string;
|
||||
statusField?: string;
|
||||
colorField?: string;
|
||||
icon: string;
|
||||
enabled: boolean;
|
||||
searchFields: string[];
|
||||
// Generate URL for deep linking to full view
|
||||
getUrl: (item: any) => string;
|
||||
// Generate snippet from content
|
||||
getSnippet?: (item: any) => string | undefined;
|
||||
}
|
||||
|
||||
const searchableEntities: SearchableEntityConfig[] = [
|
||||
{
|
||||
table: "tasks",
|
||||
type: "task",
|
||||
titleField: "title",
|
||||
snippetField: "description",
|
||||
statusField: "status",
|
||||
icon: "kanban",
|
||||
enabled: true,
|
||||
searchFields: ["title", "description"],
|
||||
getUrl: (item) => getGanttTaskUrl(String(item.id)),
|
||||
getSnippet: (item) => item.description
|
||||
? `${item.description.substring(0, 150)}${item.description.length > 150 ? "..." : ""}`
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
table: "projects",
|
||||
type: "project",
|
||||
titleField: "name",
|
||||
snippetField: "description",
|
||||
colorField: "color",
|
||||
statusField: "status",
|
||||
icon: "folder-kanban",
|
||||
enabled: true,
|
||||
searchFields: ["name", "description"],
|
||||
getUrl: (item) => getGanttProjectUrl(String(item.id)),
|
||||
getSnippet: (item) => item.description
|
||||
? `${item.description.substring(0, 150)}${item.description.length > 150 ? "..." : ""}`
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
table: "sprints",
|
||||
type: "sprint",
|
||||
titleField: "name",
|
||||
snippetField: "goal",
|
||||
statusField: "status",
|
||||
icon: "timer",
|
||||
enabled: true,
|
||||
searchFields: ["name", "goal"],
|
||||
getUrl: (item) => getGanttSprintUrl(String(item.id)),
|
||||
getSnippet: (item) => item.goal
|
||||
? `${item.goal.substring(0, 150)}${item.goal.length > 150 ? "..." : ""}`
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
table: "mission_control_documents",
|
||||
type: "document",
|
||||
titleField: "title",
|
||||
snippetField: "content",
|
||||
icon: "file-text",
|
||||
enabled: true,
|
||||
searchFields: ["title", "content"],
|
||||
getUrl: () => getMissionControlDocumentsUrl(),
|
||||
getSnippet: (item) => item.content
|
||||
? `${item.content.substring(0, 150)}${item.content.length > 150 ? "..." : ""}`
|
||||
: undefined,
|
||||
},
|
||||
// Add new searchable entities here:
|
||||
// {
|
||||
// table: "meetings",
|
||||
// type: "meeting",
|
||||
// titleField: "title",
|
||||
// icon: "calendar",
|
||||
// searchFields: ["title", "notes"],
|
||||
// getUrl: (item) => `/meetings/${item.id}`,
|
||||
// }
|
||||
];
|
||||
|
||||
// ============================================================================
|
||||
// API HANDLER
|
||||
// ============================================================================
|
||||
|
||||
export async function GET(request: Request) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = searchParams.get("q")?.trim().toLowerCase();
|
||||
|
||||
if (!query || query.length < 2) {
|
||||
return NextResponse.json({
|
||||
results: [],
|
||||
total: 0,
|
||||
query: query || ""
|
||||
});
|
||||
}
|
||||
|
||||
// Create Supabase client
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY || process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
|
||||
|
||||
if (!supabaseUrl || !supabaseKey) {
|
||||
return NextResponse.json(
|
||||
{ error: "Database not configured" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseKey);
|
||||
const results: SearchableResult[] = [];
|
||||
|
||||
// Search each enabled entity
|
||||
for (const entity of searchableEntities) {
|
||||
if (!entity.enabled) continue;
|
||||
|
||||
try {
|
||||
// Build OR filter for search fields
|
||||
const orConditions = entity.searchFields
|
||||
.map(field => `${field}.ilike.%${query}%`)
|
||||
.join(",");
|
||||
|
||||
// Only select fields we need for search results
|
||||
const selectFields = ["id", entity.titleField];
|
||||
if (entity.snippetField) selectFields.push(entity.snippetField);
|
||||
if (entity.statusField) selectFields.push(entity.statusField);
|
||||
if (entity.colorField) selectFields.push(entity.colorField);
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from(entity.table)
|
||||
.select(selectFields.join(", "))
|
||||
.or(orConditions)
|
||||
.limit(10);
|
||||
|
||||
if (error) {
|
||||
console.error(`Search error in ${entity.table}:`, error);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
const mappedResults: SearchableResult[] = data.map((item: any) => ({
|
||||
id: item.id,
|
||||
type: entity.type,
|
||||
title: item[entity.titleField] || "Untitled",
|
||||
snippet: entity.getSnippet ? entity.getSnippet(item) : undefined,
|
||||
url: entity.getUrl(item),
|
||||
icon: entity.icon,
|
||||
status: entity.statusField ? item[entity.statusField] : undefined,
|
||||
color: entity.colorField ? item[entity.colorField] : undefined,
|
||||
}));
|
||||
|
||||
results.push(...mappedResults);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Error searching ${entity.table}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by relevance: exact match > starts with > contains
|
||||
results.sort((a, b) => {
|
||||
const aTitle = a.title.toLowerCase();
|
||||
const bTitle = b.title.toLowerCase();
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
if (aTitle === lowerQuery && bTitle !== lowerQuery) return -1;
|
||||
if (bTitle === lowerQuery && aTitle !== lowerQuery) return 1;
|
||||
if (aTitle.startsWith(lowerQuery) && !bTitle.startsWith(lowerQuery)) return -1;
|
||||
if (bTitle.startsWith(lowerQuery) && !aTitle.startsWith(lowerQuery)) return 1;
|
||||
|
||||
return aTitle.localeCompare(bTitle);
|
||||
});
|
||||
|
||||
// Limit total results
|
||||
const limitedResults = results.slice(0, 50);
|
||||
|
||||
return NextResponse.json({
|
||||
results: limitedResults,
|
||||
total: limitedResults.length,
|
||||
query,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Search API error:", error);
|
||||
return NextResponse.json(
|
||||
{ error: "Search failed" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -23,6 +23,7 @@ import {
|
||||
} from "@/components/calendar";
|
||||
import { GoogleOAuthProvider } from "@react-oauth/google";
|
||||
import { format } from "date-fns";
|
||||
import { siteUrls } from "@/lib/config/sites";
|
||||
|
||||
// Google OAuth Client ID - should be from environment variable
|
||||
const GOOGLE_CLIENT_ID = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "";
|
||||
@ -145,7 +146,7 @@ function CalendarContent() {
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-1">
|
||||
<a
|
||||
href="https://calendar.google.com"
|
||||
href={siteUrls.googleCalendar}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-accent transition-colors"
|
||||
@ -153,7 +154,7 @@ function CalendarContent() {
|
||||
Open Google Calendar →
|
||||
</a>
|
||||
<a
|
||||
href="https://calendar.google.com/calendar/u/0/r/settings"
|
||||
href={siteUrls.googleCalendarSettings}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-accent transition-colors"
|
||||
@ -161,7 +162,7 @@ function CalendarContent() {
|
||||
Calendar Settings →
|
||||
</a>
|
||||
<a
|
||||
href="https://gantt-board.vercel.app"
|
||||
href={siteUrls.ganttBoard}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block w-full text-left px-3 py-2 rounded-lg text-sm hover:bg-accent transition-colors"
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { siteUrls } from "@/lib/config/sites";
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
@ -27,11 +28,11 @@ export const metadata: Metadata = {
|
||||
],
|
||||
authors: [{ name: "TopDogLabs" }],
|
||||
creator: "TopDogLabs",
|
||||
metadataBase: new URL("https://mission-control-rho-pink.vercel.app"),
|
||||
metadataBase: new URL(siteUrls.missionControl),
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: "en_US",
|
||||
url: "https://mission-control-rho-pink.vercel.app",
|
||||
url: siteUrls.missionControl,
|
||||
title: "Mission Control | TopDogLabs",
|
||||
description:
|
||||
"Central hub for activity, tasks, goals, and tools. Build an iOS empire to achieve financial independence.",
|
||||
|
||||
@ -24,6 +24,7 @@ import {
|
||||
Milestone,
|
||||
NextStep
|
||||
} from "@/lib/data/mission";
|
||||
import { siteUrls } from "@/lib/config/sites";
|
||||
|
||||
// Revalidate every 5 minutes
|
||||
export const revalidate = 300;
|
||||
@ -387,7 +388,7 @@ export default async function MissionPage() {
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" asChild className="shrink-0">
|
||||
<a href="https://gantt-board.vercel.app" target="_blank" rel="noopener noreferrer">
|
||||
<a href={siteUrls.ganttBoard} target="_blank" rel="noopener noreferrer">
|
||||
View All <ArrowRight className="w-4 h-4 ml-1" />
|
||||
</a>
|
||||
</Button>
|
||||
|
||||
@ -36,6 +36,13 @@ import {
|
||||
Sprint,
|
||||
} from "@/lib/data/projects";
|
||||
import { Task } from "@/lib/data/tasks";
|
||||
import {
|
||||
getGanttProjectUrl,
|
||||
getGanttSprintUrl,
|
||||
getGanttTaskUrl,
|
||||
getGanttTasksUrl,
|
||||
siteUrls,
|
||||
} from "@/lib/config/sites";
|
||||
|
||||
// Force dynamic rendering to fetch fresh data on each request
|
||||
export const dynamic = "force-dynamic";
|
||||
@ -123,7 +130,7 @@ function TaskListItem({ task }: { task: Task }) {
|
||||
<div className="mt-0.5 shrink-0">{getStatusIcon(task.status)}</div>
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<Link
|
||||
href={`https://gantt-board.vercel.app/tasks/${encodeURIComponent(task.id)}`}
|
||||
href={getGanttTaskUrl(task.id)}
|
||||
target="_blank"
|
||||
className="text-sm font-medium block truncate hover:text-primary hover:underline"
|
||||
>
|
||||
@ -245,7 +252,7 @@ function ProjectCard({ stats }: { stats: ProjectStats }) {
|
||||
|
||||
{/* Action Link */}
|
||||
<Link
|
||||
href={`https://gantt-board.vercel.app?project=${project.id}`}
|
||||
href={getGanttProjectUrl(project.id)}
|
||||
target="_blank"
|
||||
className="flex items-center justify-between text-xs text-primary hover:underline mt-auto pt-2"
|
||||
>
|
||||
@ -314,7 +321,7 @@ function ActiveSprintCard({ sprint, projectStats }: { sprint: Sprint; projectSta
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href={`https://gantt-board.vercel.app?sprint=${sprint.id}`}
|
||||
href={getGanttSprintUrl(sprint.id)}
|
||||
target="_blank"
|
||||
>
|
||||
<Button variant="outline" size="sm" className="w-full mt-2">
|
||||
@ -373,7 +380,7 @@ export default async function ProjectsOverviewPage() {
|
||||
<>
|
||||
Manage all projects and track progress. Edit projects in{" "}
|
||||
<Link
|
||||
href="https://gantt-board.vercel.app"
|
||||
href={siteUrls.ganttBoard}
|
||||
target="_blank"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
@ -383,7 +390,7 @@ export default async function ProjectsOverviewPage() {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Link href="https://gantt-board.vercel.app" target="_blank">
|
||||
<Link href={siteUrls.ganttBoard} target="_blank">
|
||||
<Button size="sm">
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
Open Gantt Board
|
||||
@ -534,7 +541,7 @@ export default async function ProjectsOverviewPage() {
|
||||
<div className="mt-0.5 shrink-0">{getStatusIcon(task.status)}</div>
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<Link
|
||||
href={`https://gantt-board.vercel.app/tasks/${encodeURIComponent(task.id)}`}
|
||||
href={getGanttTaskUrl(task.id)}
|
||||
target="_blank"
|
||||
className="text-sm font-medium block truncate hover:text-primary hover:underline"
|
||||
>
|
||||
@ -577,7 +584,7 @@ export default async function ProjectsOverviewPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<Link href="https://gantt-board.vercel.app" target="_blank" className="block">
|
||||
<Link href={siteUrls.ganttBoard} target="_blank" className="block">
|
||||
<Button variant="outline" className="w-full justify-start h-auto py-3">
|
||||
<div className="p-2 rounded-lg bg-blue-500/15 mr-3 shrink-0">
|
||||
<ExternalLink className="w-4 h-4 text-blue-500" />
|
||||
@ -589,7 +596,7 @@ export default async function ProjectsOverviewPage() {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="https://gantt-board.vercel.app/tasks?priority=urgent,high" target="_blank" className="block">
|
||||
<Link href={getGanttTasksUrl({ priority: "urgent,high" })} target="_blank" className="block">
|
||||
<Button variant="outline" className="w-full justify-start h-auto py-3">
|
||||
<div className="p-2 rounded-lg bg-orange-500/15 mr-3 shrink-0">
|
||||
<AlertTriangle className="w-4 h-4 text-orange-500" />
|
||||
@ -601,7 +608,7 @@ export default async function ProjectsOverviewPage() {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="https://gantt-board.vercel.app/tasks?status=in-progress" target="_blank" className="block">
|
||||
<Link href={getGanttTasksUrl({ status: "in-progress" })} target="_blank" className="block">
|
||||
<Button variant="outline" className="w-full justify-start h-auto py-3">
|
||||
<div className="p-2 rounded-lg bg-green-500/15 mr-3 shrink-0">
|
||||
<CheckCircle2 className="w-4 h-4 text-green-500" />
|
||||
@ -614,7 +621,7 @@ export default async function ProjectsOverviewPage() {
|
||||
</Link>
|
||||
|
||||
{activeSprint && (
|
||||
<Link href={`https://gantt-board.vercel.app?sprint=${activeSprint.id}`} target="_blank" className="block">
|
||||
<Link href={getGanttSprintUrl(activeSprint.id)} target="_blank" className="block">
|
||||
<Button variant="outline" className="w-full justify-start h-auto py-3">
|
||||
<div className="p-2 rounded-lg bg-purple-500/15 mr-3 shrink-0">
|
||||
<Zap className="w-4 h-4 text-purple-500" />
|
||||
@ -637,7 +644,7 @@ export default async function ProjectsOverviewPage() {
|
||||
Mission Control is read-only. Create and edit projects in gantt-board.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="https://gantt-board.vercel.app" target="_blank" className="shrink-0">
|
||||
<Link href={siteUrls.ganttBoard} target="_blank" className="shrink-0">
|
||||
<Button variant="secondary" size="sm">
|
||||
Go to gantt-board
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
import { MetadataRoute } from "next";
|
||||
import { siteUrls } from "@/lib/config/sites";
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const baseUrl = "https://mission-control-rho-pink.vercel.app";
|
||||
const baseUrl = siteUrls.missionControl;
|
||||
|
||||
return [
|
||||
{
|
||||
|
||||
@ -27,6 +27,11 @@ import {
|
||||
countOverdueTasks,
|
||||
Task,
|
||||
} from "@/lib/data/tasks";
|
||||
import {
|
||||
getGanttTaskUrl,
|
||||
getGanttTasksUrl,
|
||||
siteUrls,
|
||||
} from "@/lib/config/sites";
|
||||
|
||||
// Force dynamic rendering to fetch fresh data from Supabase on each request
|
||||
export const dynamic = "force-dynamic";
|
||||
@ -103,7 +108,7 @@ function TaskListItem({ task }: { task: Task }) {
|
||||
<div className="mt-0.5 shrink-0">{getStatusIcon(task.status)}</div>
|
||||
<div className="flex-1 min-w-0 overflow-hidden">
|
||||
<Link
|
||||
href={`https://gantt-board.vercel.app/tasks/${encodeURIComponent(task.id)}`}
|
||||
href={getGanttTaskUrl(task.id)}
|
||||
target="_blank"
|
||||
className="text-sm font-medium block truncate hover:text-primary hover:underline"
|
||||
>
|
||||
@ -248,7 +253,7 @@ export default async function TasksOverviewPage() {
|
||||
<>
|
||||
Mission Control view of all tasks. Manage work in{" "}
|
||||
<Link
|
||||
href="https://gantt-board.vercel.app"
|
||||
href={siteUrls.ganttBoard}
|
||||
target="_blank"
|
||||
className="text-primary hover:underline inline-flex items-center gap-1"
|
||||
>
|
||||
@ -258,7 +263,7 @@ export default async function TasksOverviewPage() {
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Link href="https://gantt-board.vercel.app" target="_blank">
|
||||
<Link href={siteUrls.ganttBoard} target="_blank">
|
||||
<Button size="sm" className="w-full sm:w-auto">
|
||||
<ExternalLink className="w-4 h-4 mr-2" />
|
||||
Open gantt-board
|
||||
@ -394,7 +399,7 @@ export default async function TasksOverviewPage() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 sm:gap-4">
|
||||
<Link href="https://gantt-board.vercel.app" target="_blank" className="block">
|
||||
<Link href={siteUrls.ganttBoard} target="_blank" className="block">
|
||||
<Button variant="outline" className="w-full justify-start h-auto py-3 sm:py-4">
|
||||
<div className="p-2 rounded-lg bg-blue-500/15 mr-3 shrink-0">
|
||||
<ExternalLink className="w-4 h-4 sm:w-5 sm:h-5 text-blue-500" />
|
||||
@ -406,7 +411,7 @@ export default async function TasksOverviewPage() {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="https://gantt-board.vercel.app/tasks" target="_blank" className="block">
|
||||
<Link href={getGanttTasksUrl()} target="_blank" className="block">
|
||||
<Button variant="outline" className="w-full justify-start h-auto py-3 sm:py-4">
|
||||
<div className="p-2 rounded-lg bg-purple-500/15 mr-3 shrink-0">
|
||||
<Layers className="w-4 h-4 sm:w-5 sm:h-5 text-purple-500" />
|
||||
@ -418,7 +423,7 @@ export default async function TasksOverviewPage() {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="https://gantt-board.vercel.app/tasks?priority=high,urgent" target="_blank" className="block">
|
||||
<Link href={getGanttTasksUrl({ priority: "high,urgent" })} target="_blank" className="block">
|
||||
<Button variant="outline" className="w-full justify-start h-auto py-3 sm:py-4">
|
||||
<div className="p-2 rounded-lg bg-orange-500/15 mr-3 shrink-0">
|
||||
<AlertTriangle className="w-4 h-4 sm:w-5 sm:h-5 text-orange-500" />
|
||||
@ -430,7 +435,7 @@ export default async function TasksOverviewPage() {
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<Link href="https://gantt-board.vercel.app/tasks?status=in-progress" target="_blank" className="block">
|
||||
<Link href={getGanttTasksUrl({ status: "in-progress" })} target="_blank" className="block">
|
||||
<Button variant="outline" className="w-full justify-start h-auto py-3 sm:py-4">
|
||||
<div className="p-2 rounded-lg bg-green-500/15 mr-3 shrink-0">
|
||||
<Clock className="w-4 h-4 sm:w-5 sm:h-5 text-green-500" />
|
||||
@ -452,7 +457,7 @@ export default async function TasksOverviewPage() {
|
||||
Mission Control is read-only. All task creation and editing happens in gantt-board.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="https://gantt-board.vercel.app" target="_blank" className="shrink-0">
|
||||
<Link href={siteUrls.ganttBoard} target="_blank" className="shrink-0">
|
||||
<Button variant="secondary" size="sm">
|
||||
Go to gantt-board
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
|
||||
@ -22,6 +22,7 @@ import {
|
||||
Check
|
||||
} from "lucide-react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { siteUrls } from "@/lib/config/sites";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
@ -43,35 +44,35 @@ const QUICK_LINKS: QuickLink[] = [
|
||||
{
|
||||
id: "github",
|
||||
name: "GitHub",
|
||||
url: "https://github.com",
|
||||
url: siteUrls.github,
|
||||
icon: <Github className="w-4 h-4" />,
|
||||
color: "bg-slate-800"
|
||||
},
|
||||
{
|
||||
id: "vercel",
|
||||
name: "Vercel",
|
||||
url: "https://vercel.com",
|
||||
url: siteUrls.vercel,
|
||||
icon: <Cloud className="w-4 h-4" />,
|
||||
color: "bg-black"
|
||||
},
|
||||
{
|
||||
id: "supabase",
|
||||
name: "Supabase",
|
||||
url: "https://supabase.com",
|
||||
url: siteUrls.supabase,
|
||||
icon: <Database className="w-4 h-4" />,
|
||||
color: "bg-emerald-600"
|
||||
},
|
||||
{
|
||||
id: "gantt",
|
||||
name: "Gantt Board",
|
||||
url: "https://gantt-board.vercel.app",
|
||||
url: siteUrls.ganttBoard,
|
||||
icon: <Clock className="w-4 h-4" />,
|
||||
color: "bg-blue-600"
|
||||
},
|
||||
{
|
||||
id: "google",
|
||||
name: "Google",
|
||||
url: "https://google.com",
|
||||
url: siteUrls.google,
|
||||
icon: <Globe className="w-4 h-4" />,
|
||||
color: "bg-red-500"
|
||||
},
|
||||
|
||||
@ -12,6 +12,7 @@ import { Badge } from "@/components/ui/badge";
|
||||
import { Calendar, Clock, MapPin, ExternalLink, FileText, X } from "lucide-react";
|
||||
import { format, parseISO } from "date-fns";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { siteUrls } from "@/lib/config/sites";
|
||||
|
||||
interface CalendarEvent {
|
||||
id: string;
|
||||
@ -103,7 +104,7 @@ function formatEventDate(event: CalendarEvent): string {
|
||||
export function EventDetailModal({ event, isOpen, onClose }: EventDetailModalProps) {
|
||||
if (!event) return null;
|
||||
|
||||
const eventUrl = event.htmlLink || `https://calendar.google.com/calendar/event?eid=${btoa(event.id)}`;
|
||||
const eventUrl = event.htmlLink || `${siteUrls.googleCalendar}/calendar/event?eid=${btoa(event.id)}`;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { format, isSameDay, parseISO } from "date-fns";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { siteUrls } from "@/lib/config/sites";
|
||||
|
||||
function getEventDate(event: { start: { dateTime?: string; date?: string } }): Date {
|
||||
if (event.start.dateTime) {
|
||||
@ -185,7 +186,7 @@ function EventCard({
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={`https://calendar.google.com/calendar/event?eid=${btoa(
|
||||
href={`${siteUrls.googleCalendar}/calendar/event?eid=${btoa(
|
||||
event.id
|
||||
)}`}
|
||||
target="_blank"
|
||||
|
||||
@ -15,6 +15,7 @@ import {
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { getGanttTaskUrl, getGanttTasksUrl } from "@/lib/config/sites";
|
||||
|
||||
interface TaskWithDueDate {
|
||||
id: string;
|
||||
@ -169,7 +170,7 @@ export function TaskCalendarIntegration({ maxTasks = 10 }: TaskCalendarIntegrati
|
||||
{getStatusIcon(task.status)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<Link
|
||||
href={`https://gantt-board.vercel.app/tasks/${encodeURIComponent(task.id)}`}
|
||||
href={getGanttTaskUrl(task.id)}
|
||||
target="_blank"
|
||||
className="font-medium text-sm hover:text-primary hover:underline block truncate"
|
||||
>
|
||||
@ -217,7 +218,7 @@ export function TaskCalendarIntegration({ maxTasks = 10 }: TaskCalendarIntegrati
|
||||
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<Link
|
||||
href="https://gantt-board.vercel.app/tasks"
|
||||
href={getGanttTasksUrl()}
|
||||
target="_blank"
|
||||
>
|
||||
<Button variant="ghost" size="sm" className="w-full gap-2">
|
||||
|
||||
@ -25,20 +25,23 @@ import {
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
AlertCircle,
|
||||
Timer,
|
||||
Circle,
|
||||
} from "lucide-react";
|
||||
import { supabaseClient } from "@/lib/supabase/client";
|
||||
import { siteUrls } from "@/lib/config/sites";
|
||||
|
||||
interface Task {
|
||||
// Search result type from API - matches SearchableResult interface
|
||||
type SearchResultType = "task" | "project" | "document" | "sprint";
|
||||
|
||||
interface SearchResult {
|
||||
id: string;
|
||||
type: SearchResultType;
|
||||
title: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
project_id: string;
|
||||
}
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
snippet?: string; // Brief preview text (replaces subtitle/description)
|
||||
url: string; // Deep link to full view
|
||||
icon: string;
|
||||
status?: string; // For visual badges
|
||||
color?: string; // For project/task colors
|
||||
}
|
||||
|
||||
const navItems = [
|
||||
@ -53,73 +56,74 @@ const navItems = [
|
||||
];
|
||||
|
||||
const quickLinks = [
|
||||
{ name: "Gantt Board", url: "https://gantt-board.vercel.app", icon: ExternalLink },
|
||||
{ name: "Blog Backup", url: "https://blog-backup-two.vercel.app", icon: ExternalLink },
|
||||
{ name: "Gitea", url: "http://192.168.1.128:3000", icon: ExternalLink },
|
||||
{ name: "Gantt Board", url: siteUrls.ganttBoard, icon: ExternalLink },
|
||||
{ name: "Blog Backup", url: siteUrls.blogBackup, icon: ExternalLink },
|
||||
{ name: "Gitea", url: siteUrls.gitea, icon: ExternalLink },
|
||||
];
|
||||
|
||||
function getStatusIcon(status: string) {
|
||||
// Icon mapping for search result types
|
||||
const typeIcons: Record<SearchResultType, React.ElementType> = {
|
||||
task: Kanban,
|
||||
project: FolderKanban,
|
||||
document: FileText,
|
||||
sprint: Timer,
|
||||
};
|
||||
|
||||
// Type labels
|
||||
const typeLabels: Record<SearchResultType, string> = {
|
||||
task: "Task",
|
||||
project: "Project",
|
||||
document: "Document",
|
||||
sprint: "Sprint",
|
||||
};
|
||||
|
||||
function getStatusIcon(status?: string) {
|
||||
switch (status) {
|
||||
case "done":
|
||||
case "completed":
|
||||
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
|
||||
case "in-progress":
|
||||
return <Clock className="w-4 h-4 text-blue-500" />;
|
||||
case "open":
|
||||
case "todo":
|
||||
return <Circle className="w-4 h-4 text-muted-foreground" />;
|
||||
default:
|
||||
return <AlertCircle className="w-4 h-4 text-muted-foreground" />;
|
||||
}
|
||||
}
|
||||
|
||||
function getPriorityColor(priority: string): string {
|
||||
switch (priority) {
|
||||
case "urgent":
|
||||
return "text-red-500";
|
||||
case "high":
|
||||
return "text-orange-500";
|
||||
case "medium":
|
||||
return "text-yellow-500";
|
||||
default:
|
||||
return "text-blue-500";
|
||||
}
|
||||
}
|
||||
|
||||
export function QuickSearch() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
|
||||
// Fetch tasks when search opens
|
||||
// Debounced search
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
if (!open || query.length < 2) {
|
||||
setResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const fetchData = async () => {
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
// Fetch tasks
|
||||
const { data: tasksData } = await supabaseClient
|
||||
.from('tasks')
|
||||
.select('id, title, status, priority, project_id')
|
||||
.order('updated_at', { ascending: false })
|
||||
.limit(50);
|
||||
|
||||
// Fetch projects for names
|
||||
const { data: projectsData } = await supabaseClient
|
||||
.from('projects')
|
||||
.select('id, name');
|
||||
|
||||
setTasks(tasksData || []);
|
||||
setProjects(projectsData || []);
|
||||
const res = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
|
||||
const data = await res.json();
|
||||
setResults(data.results || []);
|
||||
} catch (err) {
|
||||
console.error('Error fetching search data:', err);
|
||||
console.error("Search error:", err);
|
||||
setResults([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [open]);
|
||||
}, 150); // 150ms debounce
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [query, open]);
|
||||
|
||||
// Keyboard shortcut
|
||||
useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
||||
@ -133,13 +137,32 @@ export function QuickSearch() {
|
||||
|
||||
const runCommand = useCallback((command: () => void) => {
|
||||
setOpen(false);
|
||||
setQuery("");
|
||||
command();
|
||||
}, []);
|
||||
|
||||
const getProjectName = (projectId: string) => {
|
||||
return projects.find(p => p.id === projectId)?.name || 'Unknown';
|
||||
const handleResultSelect = (result: SearchResult) => {
|
||||
runCommand(() => {
|
||||
if (result.url?.startsWith("http")) {
|
||||
window.open(result.url, "_blank");
|
||||
} else if (result.url) {
|
||||
router.push(result.url);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Group results by type
|
||||
const groupedResults = results.reduce((acc, result) => {
|
||||
if (!acc[result.type]) acc[result.type] = [];
|
||||
acc[result.type].push(result);
|
||||
return acc;
|
||||
}, {} as Record<SearchResultType, SearchResult[]>);
|
||||
|
||||
// Get ordered list of types that have results
|
||||
const activeTypes = (Object.keys(groupedResults) as SearchResultType[]).filter(
|
||||
(type) => groupedResults[type]?.length > 0
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Search Button Trigger */}
|
||||
@ -157,9 +180,63 @@ export function QuickSearch() {
|
||||
|
||||
{/* Command Dialog */}
|
||||
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||
<CommandInput placeholder="Type a command or search..." />
|
||||
<CommandInput
|
||||
placeholder="Search tasks, projects, sprints, documents..."
|
||||
value={query}
|
||||
onValueChange={setQuery}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>No results found.</CommandEmpty>
|
||||
<CommandEmpty>
|
||||
{loading ? "Searching..." : query.length < 2 ? "Type to search..." : "No results found."}
|
||||
</CommandEmpty>
|
||||
|
||||
{/* Show search results when query exists */}
|
||||
{query.length >= 2 && activeTypes.length > 0 && (
|
||||
<>
|
||||
{activeTypes.map((type) => {
|
||||
const Icon = typeIcons[type];
|
||||
const typeResults = groupedResults[type];
|
||||
|
||||
return (
|
||||
<CommandGroup key={type} heading={`${typeLabels[type]}s (${typeResults.length})`}>
|
||||
{typeResults.slice(0, 5).map((result) => (
|
||||
<CommandItem
|
||||
key={`${result.type}-${result.id}`}
|
||||
onSelect={() => handleResultSelect(result)}
|
||||
>
|
||||
{result.type === "task" ? (
|
||||
getStatusIcon(result.status)
|
||||
) : result.type === "project" && result.color ? (
|
||||
<div
|
||||
className="w-3 h-3 rounded-full mr-2"
|
||||
style={{ backgroundColor: result.color }}
|
||||
/>
|
||||
) : (
|
||||
<Icon className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
|
||||
<div className="flex flex-col flex-1 min-w-0">
|
||||
<span className="truncate">{result.title}</span>
|
||||
{result.snippet && (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
{result.snippet}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result.status && result.type !== "task" && (
|
||||
<span className="text-xs text-muted-foreground ml-2 capitalize">
|
||||
{result.status}
|
||||
</span>
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
);
|
||||
})}
|
||||
<CommandSeparator />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<CommandGroup heading="Navigation">
|
||||
@ -194,39 +271,8 @@ export function QuickSearch() {
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
|
||||
{/* Tasks */}
|
||||
{tasks.length > 0 && (
|
||||
<>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading={`Tasks (${tasks.length})`}>
|
||||
{tasks.slice(0, 10).map((task) => (
|
||||
<CommandItem
|
||||
key={task.id}
|
||||
onSelect={() =>
|
||||
runCommand(() =>
|
||||
window.open(
|
||||
`https://gantt-board.vercel.app/tasks/${task.id}`,
|
||||
"_blank"
|
||||
)
|
||||
)
|
||||
}
|
||||
>
|
||||
{getStatusIcon(task.status)}
|
||||
<span className="mr-2 truncate">{task.title}</span>
|
||||
<span className={`text-xs ${getPriorityColor(task.priority)}`}>
|
||||
{task.priority}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{getProjectName(task.project_id)}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
279
docs/sso-implementation-plan.md
Normal file
279
docs/sso-implementation-plan.md
Normal file
@ -0,0 +1,279 @@
|
||||
# Shared SSO Implementation Plan (Mission Control + Related Apps)
|
||||
|
||||
## Summary
|
||||
Implement single sign-on across apps by using one parent domain and one shared session cookie, with Supabase Auth as the identity source and a central auth origin (`auth.topdoglabs.com`).
|
||||
Use one dedicated auth project/service for this logic so auth code is not duplicated across app projects.
|
||||
|
||||
This gives:
|
||||
- One login across apps
|
||||
- Silent cross-app authentication
|
||||
- Global logout
|
||||
|
||||
---
|
||||
|
||||
## Chosen Decisions
|
||||
1. Use custom subdomains under one parent domain.
|
||||
2. Keep Supabase as the central identity provider.
|
||||
3. Use silent auto-login between apps.
|
||||
4. Use global logout across apps.
|
||||
|
||||
---
|
||||
|
||||
## Target Domain Layout
|
||||
- `auth.topdoglabs.com` -> central auth service
|
||||
- `mission.topdoglabs.com` -> Mission Control
|
||||
- `gantt.topdoglabs.com` -> Gantt Board
|
||||
- (optional) `blog.topdoglabs.com` -> Blog Backup
|
||||
|
||||
Important: shared-cookie SSO will not work across `*.vercel.app` app URLs.
|
||||
|
||||
---
|
||||
|
||||
## Auth and Session Architecture
|
||||
1. Centralize login/logout/session endpoints on `auth.topdoglabs.com`.
|
||||
2. Keep credentials and user identity in Supabase Auth.
|
||||
3. Use a shared `sessions` table in Supabase for web sessions.
|
||||
4. Issue one cookie for all subdomains:
|
||||
- Name: `tdl_sso_session`
|
||||
- `Domain=.topdoglabs.com`
|
||||
- `Path=/`
|
||||
- `HttpOnly`
|
||||
- `Secure` (production)
|
||||
- `SameSite=Lax`
|
||||
5. Each app middleware:
|
||||
- reads `tdl_sso_session`
|
||||
- validates via auth/session endpoint
|
||||
- redirects to auth login if missing/invalid, with `return_to` URL
|
||||
6. Logout from any app:
|
||||
- revoke server session
|
||||
- clear shared cookie for `.topdoglabs.com`
|
||||
- signed out everywhere
|
||||
|
||||
---
|
||||
|
||||
## API Contracts
|
||||
### `POST /api/sso/login` (auth domain)
|
||||
Input:
|
||||
```json
|
||||
{ "email": "user@example.com", "password": "********", "rememberMe": true }
|
||||
```
|
||||
Behavior:
|
||||
- authenticates against Supabase Auth
|
||||
- creates shared session row
|
||||
- sets shared cookie
|
||||
|
||||
Response:
|
||||
```json
|
||||
{ "success": true, "user": { "...": "..." }, "session": { "expiresAt": "ISO8601", "rememberMe": true } }
|
||||
```
|
||||
|
||||
### `GET /api/sso/session` (auth domain)
|
||||
Behavior:
|
||||
- reads `tdl_sso_session`
|
||||
- validates against sessions table
|
||||
|
||||
Response:
|
||||
```json
|
||||
{ "authenticated": true, "user": { "...": "..." } }
|
||||
```
|
||||
or
|
||||
```json
|
||||
{ "authenticated": false }
|
||||
```
|
||||
|
||||
### `POST /api/sso/logout` (auth domain)
|
||||
Behavior:
|
||||
- revokes current session in DB
|
||||
- clears shared cookie on `.topdoglabs.com`
|
||||
|
||||
Response:
|
||||
```json
|
||||
{ "success": true }
|
||||
```
|
||||
|
||||
### `GET /api/sso/authorize?return_to=<url>` (auth domain)
|
||||
Behavior:
|
||||
- if authenticated: redirect (302) to `return_to`
|
||||
- if unauthenticated: redirect to login
|
||||
- validate `return_to` against allowlist
|
||||
|
||||
---
|
||||
|
||||
## Data Model (Supabase)
|
||||
Use/standardize `sessions` table:
|
||||
- `user_id` (uuid, fk)
|
||||
- `token_hash` (text, unique)
|
||||
- `created_at` (timestamptz)
|
||||
- `expires_at` (timestamptz)
|
||||
- `revoked_at` (timestamptz, nullable)
|
||||
|
||||
Recommended optional fields:
|
||||
- `last_seen_at`
|
||||
- `ip`
|
||||
- `user_agent`
|
||||
- `remember_me`
|
||||
|
||||
Indexes:
|
||||
- `token_hash`
|
||||
- `user_id`
|
||||
- `expires_at`
|
||||
|
||||
Security:
|
||||
- store only SHA-256 hash of token
|
||||
- never store raw session token
|
||||
|
||||
---
|
||||
|
||||
## Vercel Subdomain Setup (Step-by-Step)
|
||||
1. In each Vercel project, open `Settings -> Domains`.
|
||||
2. Add the desired subdomain for that specific project:
|
||||
- Mission Control project -> `mission.topdoglabs.com`
|
||||
- Gantt project -> `gantt.topdoglabs.com`
|
||||
- Auth project -> `auth.topdoglabs.com`
|
||||
3. If domain DNS is managed by Vercel nameservers, Vercel usually auto-creates the needed records.
|
||||
4. If DNS is external:
|
||||
- create the exact DNS record shown by Vercel on the domain card
|
||||
- subdomains are typically `CNAME`
|
||||
- apex/root is typically `A`
|
||||
5. Wait for each to show verified/valid in Vercel.
|
||||
6. Keep the `*.vercel.app` URLs for testing, but use custom subdomains for production SSO.
|
||||
|
||||
---
|
||||
|
||||
## Subdomain Knowledge (Quick Primer)
|
||||
### What a subdomain is
|
||||
- A subdomain is a child host under a parent domain.
|
||||
- Example:
|
||||
- parent/root domain: `topdoglabs.com`
|
||||
- subdomains: `mission.topdoglabs.com`, `gantt.topdoglabs.com`, `auth.topdoglabs.com`
|
||||
|
||||
### Why this matters for SSO
|
||||
- Browsers only allow one site to share a cookie with another site when both are under the same parent domain and the cookie `Domain` is set to that parent.
|
||||
- For your case: set cookie `Domain=.topdoglabs.com` so all `*.topdoglabs.com` apps can read/send it.
|
||||
- `mission-control-rho-pink.vercel.app` and `gantt-board.vercel.app` cannot share a parent-domain cookie with each other.
|
||||
|
||||
### DNS records you will see
|
||||
- Subdomain to app mapping is commonly a `CNAME` record.
|
||||
- Apex/root domain usually uses `A`/`ALIAS`/`ANAME` depending on DNS provider.
|
||||
- In Vercel, always follow the exact record values shown in each project’s Domains panel.
|
||||
|
||||
### SSL/TLS on subdomains
|
||||
- Vercel provisions certificates for added custom domains/subdomains after verification.
|
||||
- SSO cookies with `Secure` require HTTPS in production, so certificate readiness is required before final auth testing.
|
||||
|
||||
### Cookie scope rules (important)
|
||||
- `Domain=.topdoglabs.com`: cookie is sent to all subdomains under `topdoglabs.com`.
|
||||
- `Domain=mission.topdoglabs.com` or no `Domain` set: cookie is host-only, not shared with other subdomains.
|
||||
- `SameSite=Lax`: good default for first-party web app navigation between your subdomains.
|
||||
|
||||
### Recommended host roles
|
||||
- `auth.topdoglabs.com`: login, logout, session validation endpoints
|
||||
- `mission.topdoglabs.com`: Mission Control app
|
||||
- `gantt.topdoglabs.com`: Gantt Board app
|
||||
- Optional static/marketing hosts can stay separate, but app surfaces participating in SSO should remain under `*.topdoglabs.com`.
|
||||
|
||||
---
|
||||
|
||||
## Subdomain Rollout Checklist
|
||||
1. Add `mission.topdoglabs.com` to Mission Control Vercel project.
|
||||
2. Add `gantt.topdoglabs.com` to Gantt project.
|
||||
3. Add `auth.topdoglabs.com` to auth project (or auth routes host).
|
||||
4. Verify each domain shows valid configuration and active HTTPS.
|
||||
5. Update environment variables:
|
||||
- `AUTH_ORIGIN=https://auth.topdoglabs.com`
|
||||
- `COOKIE_DOMAIN=.topdoglabs.com`
|
||||
6. Deploy auth cookie changes with `Domain=.topdoglabs.com`.
|
||||
7. Test login on one app, then open the other app in a new tab.
|
||||
8. Test global logout propagation across both apps.
|
||||
|
||||
---
|
||||
|
||||
## Common Subdomain Troubleshooting
|
||||
1. Symptom: still getting separate logins.
|
||||
- Check cookie `Domain` is `.topdoglabs.com`, not host-only.
|
||||
2. Symptom: login works on one app but not another.
|
||||
- Confirm both apps are truly on `*.topdoglabs.com`, not `*.vercel.app`.
|
||||
3. Symptom: cookie not set in production.
|
||||
- Confirm HTTPS is active and cookie has `Secure`.
|
||||
4. Symptom: redirect loops.
|
||||
- Check `return_to` allowlist and ensure auth/session endpoint trusts both app origins.
|
||||
5. Symptom: logout not global.
|
||||
- Ensure logout revokes DB session and clears cookie with same name + same domain/path attributes.
|
||||
|
||||
---
|
||||
|
||||
## Reusable Pattern for Future Websites
|
||||
Use this as the standard for any new web app that should join shared SSO.
|
||||
|
||||
### Onboarding standard for a new app
|
||||
1. Put the app on a subdomain under `*.topdoglabs.com` (for example `newapp.topdoglabs.com`).
|
||||
2. Add middleware/route guard that checks `tdl_sso_session`.
|
||||
3. If no valid session, redirect to `https://auth.topdoglabs.com/login?return_to=<current_url>`.
|
||||
4. On sign-out, call central logout endpoint (`POST /api/sso/logout`) instead of local-only logout.
|
||||
5. Ensure app URLs are added to auth `return_to` allowlist.
|
||||
|
||||
### Required environment variables (per app)
|
||||
- `AUTH_ORIGIN=https://auth.topdoglabs.com`
|
||||
- `COOKIE_NAME=tdl_sso_session`
|
||||
- `COOKIE_DOMAIN=.topdoglabs.com`
|
||||
|
||||
### Required auth behavior (per app)
|
||||
- Do not create app-specific login cookie names for SSO surfaces.
|
||||
- Do not set host-only session cookies for authenticated app pages.
|
||||
- Treat auth service as source of truth for session validity.
|
||||
|
||||
### Minimal integration contract
|
||||
- App must be able to:
|
||||
- redirect unauthenticated users to auth origin with `return_to`
|
||||
- accept authenticated return and continue to requested page
|
||||
- trigger global logout
|
||||
- handle expired/revoked session as immediate signed-out state
|
||||
|
||||
### Naming convention recommendation
|
||||
- Domain pattern: `<app>.topdoglabs.com`
|
||||
- Session cookie: `tdl_sso_session`
|
||||
- Auth host: `auth.topdoglabs.com`
|
||||
|
||||
This keeps every future app consistent and avoids one-off auth logic.
|
||||
|
||||
---
|
||||
|
||||
## Migration Plan
|
||||
1. Move apps to custom subdomains.
|
||||
2. Introduce shared cookie `tdl_sso_session`.
|
||||
3. Update auth cookie set/clear to include `Domain=.topdoglabs.com`.
|
||||
4. Centralize auth endpoints on `auth.topdoglabs.com`.
|
||||
5. Update app middleware to redirect to auth domain with `return_to`.
|
||||
6. Keep legacy app-local cookie compatibility for 7-14 days.
|
||||
7. Remove legacy cookie logic after rollout.
|
||||
|
||||
---
|
||||
|
||||
## Security Controls
|
||||
1. Strict allowlist for `return_to` URLs (prevent open redirects).
|
||||
2. CSRF protection on login/logout POST endpoints.
|
||||
3. Session TTL:
|
||||
- short session: 12 hours
|
||||
- remember me: 30 days
|
||||
4. Treat expired/revoked sessions as immediate logout.
|
||||
5. Rotate session token on sensitive account actions.
|
||||
|
||||
---
|
||||
|
||||
## Test Cases
|
||||
1. Login once, open app A then app B, no second login prompt.
|
||||
2. Deep-link directly into app B while logged in from app A.
|
||||
3. Logout in app A, refresh app B, user is logged out.
|
||||
4. Expired cookie redirects to auth login.
|
||||
5. Revoked session is denied in all apps.
|
||||
6. Invalid `return_to` is rejected and replaced by safe default.
|
||||
7. Remember-me survives browser restart; non-remember does not.
|
||||
8. Cookie flags validated in production (`Secure`, `HttpOnly`, domain-scoped).
|
||||
|
||||
---
|
||||
|
||||
## Assumptions
|
||||
1. Both apps remain in the same Supabase project/database.
|
||||
2. Both apps can be served under `*.topdoglabs.com`.
|
||||
3. Global logout means current browser session is invalidated across all apps.
|
||||
4. Existing `mission_control_session` is replaced by `tdl_sso_session`.
|
||||
98
lib/config/sites.ts
Normal file
98
lib/config/sites.ts
Normal file
@ -0,0 +1,98 @@
|
||||
type SiteUrlKey =
|
||||
| "missionControl"
|
||||
| "ganttBoard"
|
||||
| "blogBackup"
|
||||
| "gitea"
|
||||
| "github"
|
||||
| "vercel"
|
||||
| "supabase"
|
||||
| "google"
|
||||
| "googleCalendar"
|
||||
| "googleCalendarSettings";
|
||||
|
||||
const DEFAULT_SITE_URLS: Record<SiteUrlKey, string> = {
|
||||
missionControl: "https://mission-control-rho-pink.vercel.app",
|
||||
ganttBoard: "https://gantt-board.vercel.app",
|
||||
blogBackup: "https://blog-backup-two.vercel.app",
|
||||
gitea: "http://192.168.1.128:3000",
|
||||
github: "https://github.com",
|
||||
vercel: "https://vercel.com",
|
||||
supabase: "https://supabase.com",
|
||||
google: "https://google.com",
|
||||
googleCalendar: "https://calendar.google.com",
|
||||
googleCalendarSettings: "https://calendar.google.com/calendar/u/0/r/settings",
|
||||
};
|
||||
|
||||
const ENV_SITE_URLS: Partial<Record<SiteUrlKey, string | undefined>> = {
|
||||
missionControl: process.env.NEXT_PUBLIC_MISSION_CONTROL_URL,
|
||||
ganttBoard: process.env.NEXT_PUBLIC_GANTT_BOARD_URL,
|
||||
blogBackup: process.env.NEXT_PUBLIC_BLOG_BACKUP_URL,
|
||||
gitea: process.env.NEXT_PUBLIC_GITEA_URL,
|
||||
github: process.env.NEXT_PUBLIC_GITHUB_URL,
|
||||
vercel: process.env.NEXT_PUBLIC_VERCEL_URL,
|
||||
supabase: process.env.NEXT_PUBLIC_SUPABASE_SITE_URL,
|
||||
google: process.env.NEXT_PUBLIC_GOOGLE_URL,
|
||||
googleCalendar: process.env.NEXT_PUBLIC_GOOGLE_CALENDAR_URL,
|
||||
googleCalendarSettings: process.env.NEXT_PUBLIC_GOOGLE_CALENDAR_SETTINGS_URL,
|
||||
};
|
||||
|
||||
function normalizeBaseUrl(value: string, fallback: string): string {
|
||||
try {
|
||||
const normalized = new URL(value.trim()).toString();
|
||||
return normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
const configuredSiteUrls = Object.entries(DEFAULT_SITE_URLS).reduce(
|
||||
(acc, [key, fallback]) => {
|
||||
const envValue = ENV_SITE_URLS[key as SiteUrlKey];
|
||||
acc[key as SiteUrlKey] = normalizeBaseUrl(envValue || fallback, fallback);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<SiteUrlKey, string>,
|
||||
);
|
||||
|
||||
export const siteUrls = Object.freeze(configuredSiteUrls);
|
||||
|
||||
function toUrl(base: string, path = ""): string {
|
||||
if (!path) return base;
|
||||
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||
return `${base}${normalizedPath}`;
|
||||
}
|
||||
|
||||
function toUrlWithQuery(
|
||||
base: string,
|
||||
path: string,
|
||||
params: Record<string, string | undefined>,
|
||||
): string {
|
||||
const query = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value) query.set(key, value);
|
||||
}
|
||||
const queryString = query.toString();
|
||||
return queryString ? `${toUrl(base, path)}?${queryString}` : toUrl(base, path);
|
||||
}
|
||||
|
||||
export function getGanttTaskUrl(taskId: string): string {
|
||||
return toUrl(siteUrls.ganttBoard, `/tasks/${encodeURIComponent(taskId)}`);
|
||||
}
|
||||
|
||||
export function getGanttProjectUrl(projectId: string): string {
|
||||
return toUrl(siteUrls.ganttBoard, `/projects/${encodeURIComponent(projectId)}`);
|
||||
}
|
||||
|
||||
export function getGanttSprintUrl(sprintId: string): string {
|
||||
return toUrl(siteUrls.ganttBoard, `/sprints/${encodeURIComponent(sprintId)}`);
|
||||
}
|
||||
|
||||
export function getGanttTasksUrl(params?: Record<string, string | undefined>): string {
|
||||
return params
|
||||
? toUrlWithQuery(siteUrls.ganttBoard, "/tasks", params)
|
||||
: toUrl(siteUrls.ganttBoard, "/tasks");
|
||||
}
|
||||
|
||||
export function getMissionControlDocumentsUrl(): string {
|
||||
return toUrl(siteUrls.missionControl, "/documents");
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import { getServiceSupabase } from "@/lib/supabase/client";
|
||||
import { Task, fetchAllTasks } from "./tasks";
|
||||
import { Project, fetchAllProjects } from "./projects";
|
||||
import { getGanttTaskUrl } from "@/lib/config/sites";
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
@ -370,7 +371,7 @@ export async function getNextMissionSteps(): Promise<NextStep[]> {
|
||||
priority: task.priority as "high" | "urgent",
|
||||
projectName: projectMap.get(task.project_id),
|
||||
dueDate: task.due_date,
|
||||
ganttBoardUrl: `https://gantt-board.vercel.app/?task=${task.id}`,
|
||||
ganttBoardUrl: getGanttTaskUrl(task.id),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "next dev -p 3001",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
|
||||
27
search.ts
Normal file
27
search.ts
Normal file
@ -0,0 +1,27 @@
|
||||
const { createClient } = require('@supabase/supabase-js');
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseKey);
|
||||
|
||||
async function searchMissionControl() {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('mission_control_documents')
|
||||
.select('title, content')
|
||||
.or('title.ilike.%mission%,content.ilike.%mission%');
|
||||
|
||||
if (error) {
|
||||
console.error('Query error:', error);
|
||||
} else {
|
||||
console.log('Search results:', data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Unexpected error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Searching for: Mission Control Strategy Plan')
|
||||
|
||||
searchMissionControl();
|
||||
25
test-search.js
Normal file
25
test-search.js
Normal file
@ -0,0 +1,25 @@
|
||||
const { createClient } = require('@supabase/supabase-js');
|
||||
|
||||
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
const supabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
|
||||
const supabase = createClient(supabaseUrl, supabaseKey);
|
||||
|
||||
async function testSearch() {
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('mission_control_documents')
|
||||
.select('title, content')
|
||||
.or('title.ilike.%mission%,content.ilike.%mission%');
|
||||
|
||||
if (error) {
|
||||
console.error('Query error:', error);
|
||||
} else {
|
||||
console.log('Search results:', data);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Unexpected error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
testSearch();
|
||||
Loading…
Reference in New Issue
Block a user