Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 63 additions & 1 deletion src/app/(dashboard)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import Link from "next/link";
import { useRouter } from "next/navigation";
import { signOut, useSession } from "next-auth/react";
import { useQuery } from "@tanstack/react-query";
import { LogOut, User } from "lucide-react";
import { LogOut, ShieldAlert, User } from "lucide-react";

import { useTRPC } from "@/trpc/client";
import { AppSidebar } from "@/components/app-sidebar";
Expand Down Expand Up @@ -51,6 +51,10 @@ export default function DashboardLayout({
return "U";
})();

const teamsQuery = useQuery(trpc.team.list.queryOptions());
const teams = teamsQuery.data ?? [];
const isTeamless = teamsQuery.isSuccess && teams.length === 0;

const [passwordDialogOpen, setPasswordDialogOpen] = useState(false);
// Force password change dialog when mustChangePassword is set
useEffect(() => {
Expand All @@ -68,6 +72,64 @@ export default function DashboardLayout({
}
}, [me, router]);

if (isTeamless) {
return (
<div className="flex min-h-screen flex-col">
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-4">
<div className="ml-auto flex items-center gap-2">
<ThemeToggle />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="rounded-full" aria-label="User menu">
<Avatar size="sm">
{userImage && <AvatarImage src={userImage} alt={userName ?? "User"} />}
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">{userName ?? "User"}</p>
{userEmail && (
<p className="text-xs leading-none text-muted-foreground">{userEmail}</p>
)}
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => signOut({ callbackUrl: "/login" })}>
<LogOut className="mr-2 h-4 w-4" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</header>
<div className="flex flex-1 items-center justify-center">
<div className="mx-auto max-w-md text-center space-y-4">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-muted">
<ShieldAlert className="h-8 w-8 text-muted-foreground" />
</div>
<h1 className="text-2xl font-semibold">No Team Assigned</h1>
<p className="text-muted-foreground">
Your account is active but you haven&apos;t been assigned to a team yet. Contact your administrator to get access.
</p>
{(userName || userEmail) && (
<p className="text-sm text-muted-foreground">
Signed in as <span className="font-medium text-foreground">{userName || userEmail}</span>
</p>
)}
<Button variant="outline" onClick={() => signOut({ callbackUrl: "/login" })}>
<LogOut className="mr-2 h-4 w-4" />
Sign Out
</Button>
</div>
</div>
<ChangePasswordDialog open={passwordDialogOpen} onOpenChange={setPasswordDialogOpen} forced={me?.mustChangePassword} />
</div>
);
}

return (
<SidebarProvider>
<AppSidebar />
Expand Down
148 changes: 124 additions & 24 deletions src/app/(dashboard)/settings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -467,7 +467,32 @@ function AuthSettings() {
testOidcMutation.mutate({ issuer });
};

const [teamMappings, setTeamMappings] = useState<Array<{group: string; teamId: string; role: "VIEWER" | "EDITOR" | "ADMIN"}>>([]);
const [teamMappings, setTeamMappings] = useState<Array<{group: string; teamIds: string[]; role: "VIEWER" | "EDITOR" | "ADMIN"}>>([]);

function mergeMappings(
flat: Array<{ group: string; teamId: string; role: string }>
): Array<{ group: string; teamIds: string[]; role: "VIEWER" | "EDITOR" | "ADMIN" }> {
const map = new Map<string, { group: string; teamIds: string[]; role: "VIEWER" | "EDITOR" | "ADMIN" }>();
for (const m of flat) {
const key = `${m.group}::${m.role}`;
const existing = map.get(key);
if (existing) {
existing.teamIds.push(m.teamId);
} else {
map.set(key, { group: m.group, teamIds: [m.teamId], role: m.role as "VIEWER" | "EDITOR" | "ADMIN" });
}
}
return [...map.values()];
}

function flattenMappings(
grouped: Array<{ group: string; teamIds: string[]; role: "VIEWER" | "EDITOR" | "ADMIN" }>
): Array<{ group: string; teamId: string; role: "VIEWER" | "EDITOR" | "ADMIN" }> {
return grouped.flatMap((row) =>
row.teamIds.map((teamId) => ({ group: row.group, teamId, role: row.role }))
);
}

const [defaultTeamId, setDefaultTeamId] = useState("");
const [defaultRole, setDefaultRole] = useState<"VIEWER" | "EDITOR" | "ADMIN">("VIEWER");
const [groupSyncEnabled, setGroupSyncEnabled] = useState(false);
Expand All @@ -483,7 +508,9 @@ function AuthSettings() {
setGroupSyncEnabled(settings.oidcGroupSyncEnabled ?? false);
setGroupsScope(settings.oidcGroupsScope ?? "");
setGroupsClaim(settings.oidcGroupsClaim ?? "groups");
setTeamMappings((settings.oidcTeamMappings ?? []) as Array<{group: string; teamId: string; role: "VIEWER" | "EDITOR" | "ADMIN"}>);
setTeamMappings(
mergeMappings((settings.oidcTeamMappings ?? []) as Array<{group: string; teamId: string; role: string}>)
);
setDefaultTeamId(settings.oidcDefaultTeamId ?? "");
}, [settings, isDirty]);

Expand All @@ -504,18 +531,25 @@ function AuthSettings() {

function addMapping() {
markDirty();
setTeamMappings([...teamMappings, { group: "", teamId: "", role: "VIEWER" }]);
setTeamMappings([...teamMappings, { group: "", teamIds: [], role: "VIEWER" }]);
}

function removeMapping(index: number) {
markDirty();
setTeamMappings(teamMappings.filter((_, i) => i !== index));
}

function updateMapping(index: number, field: keyof typeof teamMappings[number], value: string) {
function updateMapping(index: number, field: "group" | "role", value: string) {
markDirty();
setTeamMappings(teamMappings.map((m, i) =>
i === index ? { ...m, [field]: value } : m
));
}

function updateMappingTeams(index: number, teamIds: string[]) {
markDirty();
setTeamMappings(teamMappings.map((m, i) =>
i === index ? { ...m, [field]: value } as typeof m : m
i === index ? { ...m, teamIds } : m
));
}

Expand Down Expand Up @@ -688,7 +722,7 @@ function AuthSettings() {
<form onSubmit={(e) => {
e.preventDefault();
updateTeamMappingMutation.mutate({
mappings: teamMappings.filter((m) => m.group && m.teamId),
mappings: flattenMappings(teamMappings).filter((m) => m.group && m.teamId),
defaultTeamId: defaultTeamId || undefined,
defaultRole,
groupSyncEnabled,
Expand Down Expand Up @@ -762,19 +796,57 @@ function AuthSettings() {
/>
</TableCell>
<TableCell>
<Select
value={mapping.teamId}
onValueChange={(val) => updateMapping(index, "teamId", val)}
>
<SelectTrigger>
<SelectValue placeholder="Select team" />
</SelectTrigger>
<SelectContent>
{(teamsQuery.data ?? []).map((t) => (
<SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>
))}
</SelectContent>
</Select>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" type="button" className="w-full justify-start text-left font-normal">
{mapping.teamIds.length === 0 && (
<span className="text-muted-foreground">Select teams...</span>
)}
{mapping.teamIds.length === 1 && (
<span>{(teamsQuery.data ?? []).find((t) => t.id === mapping.teamIds[0])?.name ?? "Unknown"}</span>
)}
{mapping.teamIds.length > 1 && (
<Badge variant="secondary" className="text-xs">
{mapping.teamIds.length} teams
</Badge>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-56 p-2" align="start">
<div className="space-y-1">
{(teamsQuery.data ?? []).map((team) => {
const checked = mapping.teamIds.includes(team.id);
return (
<button
key={team.id}
type="button"
className="flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm hover:bg-accent"
onClick={() => {
const next = checked
? mapping.teamIds.filter((id) => id !== team.id)
: [...mapping.teamIds, team.id];
updateMappingTeams(index, next);
}}
>
<div className={`flex h-4 w-4 items-center justify-center rounded-sm border ${checked ? "bg-primary border-primary" : "border-muted-foreground/30"}`}>
{checked && <CheckCircle2 className="h-3 w-3 text-primary-foreground" />}
</div>
<span>{team.name}</span>
{checked && (
<X
className="ml-auto h-3 w-3 text-muted-foreground hover:text-foreground"
onClick={(e) => {
e.stopPropagation();
updateMappingTeams(index, mapping.teamIds.filter((id) => id !== team.id));
}}
/>
)}
</button>
);
})}
</div>
</PopoverContent>
</Popover>
</TableCell>
<TableCell>
<Select
Expand Down Expand Up @@ -1944,15 +2016,15 @@ function UsersSettings() {
<Popover>
<PopoverTrigger asChild>
<button className="flex items-center gap-1 rounded-md hover:bg-muted/50 px-1 py-0.5 transition-colors">
{user.memberships.slice(0, 2).map((m) => (
{user.memberships.slice(0, 1).map((m) => (
<Badge key={m.team.id} variant="outline" className="text-xs">
{m.team.name}
</Badge>
))}
{user.memberships.length > 2 && (
<span className="text-xs text-muted-foreground">
+{user.memberships.length - 2} more
</span>
{user.memberships.length > 1 && (
<Badge variant="secondary" className="text-xs">
{user.memberships.length} teams
</Badge>
)}
</button>
</PopoverTrigger>
Expand Down Expand Up @@ -2854,6 +2926,22 @@ function BackupSettings() {
</CardContent>
</Card>

{/* Failed Backup Alert */}
{settingsQuery.data?.lastBackupStatus === "failed" && (
<Card className="border-destructive/50">
<CardContent className="flex items-start gap-3 p-4">
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
<div className="text-sm">
<p className="font-medium text-destructive">Last backup failed</p>
<p className="text-muted-foreground">
{settingsQuery.data.lastBackupError || "Unknown error"} &mdash;{" "}
{formatRelativeTime(settingsQuery.data.lastBackupAt)}
</p>
</div>
</CardContent>
</Card>
)}

{/* Manual Backup */}
<Card>
<CardHeader>
Expand Down Expand Up @@ -2928,6 +3016,18 @@ function BackupSettings() {
<TableCell>{backup.migrationCount}</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-2">
<Button
variant="ghost"
size="sm"
asChild
>
<a
href={`/api/backups/${encodeURIComponent(backup.filename)}/download`}
download
>
<Download className="h-4 w-4" />
</a>
</Button>
<Button
variant="outline"
size="sm"
Expand Down
67 changes: 67 additions & 0 deletions src/app/api/backups/[filename]/download/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { auth } from "@/auth";
import { prisma } from "@/lib/prisma";
import fs from "fs/promises";
import path from "path";
import { createReadStream } from "fs";
import { Readable } from "stream";

const BACKUP_DIR = process.env.VF_BACKUP_DIR ?? "/backups";

function sanitizeFilename(filename: string): string {
const base = path.basename(filename);
if (!/^[\w.\-]+$/.test(base)) {
throw new Error("Invalid filename");
}
return base;
}

export async function GET(
_request: Request,
{ params }: { params: Promise<{ filename: string }> }
) {
const session = await auth();
if (!session?.user?.id) {
return new Response("Unauthorized", { status: 401 });
}

const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { isSuperAdmin: true },
});

if (!user?.isSuperAdmin) {
return new Response("Forbidden", { status: 403 });
}

const { filename } = await params;
let safe: string;
try {
safe = sanitizeFilename(filename);
} catch {
return new Response("Invalid filename", { status: 400 });
}

if (!safe.endsWith(".dump")) {
return new Response("Invalid backup filename", { status: 400 });
}

const filePath = path.join(BACKUP_DIR, safe);

try {
await fs.access(filePath);
} catch {
return new Response("Backup not found", { status: 404 });
}

const stat = await fs.stat(filePath);
const stream = createReadStream(filePath);
const webStream = Readable.toWeb(stream) as ReadableStream;
Comment on lines +56 to +58
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fs.stat and createReadStream unprotected after fs.access check

The fs.access check at line 51 is wrapped in a try/catch that returns a clean 404, but fs.stat and createReadStream (lines 56–58) are outside any error handler. If the .dump file is removed between the fs.access check and the subsequent calls (e.g., during a concurrent backup cleanup), fs.stat will throw an unhandled exception and Next.js will return a 500 error instead of a graceful 404.

The simpler fix is to drop the redundant fs.access check and use fs.stat directly inside the try/catch:

Suggested change
const stat = await fs.stat(filePath);
const stream = createReadStream(filePath);
const webStream = Readable.toWeb(stream) as ReadableStream;
let stat: Awaited<ReturnType<typeof fs.stat>>;
try {
stat = await fs.stat(filePath);
} catch {
return new Response("Backup not found", { status: 404 });
}
const stream = createReadStream(filePath);
const webStream = Readable.toWeb(stream) as ReadableStream;
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/api/backups/[filename]/download/route.ts
Line: 56-58

Comment:
**`fs.stat` and `createReadStream` unprotected after `fs.access` check**

The `fs.access` check at line 51 is wrapped in a try/catch that returns a clean 404, but `fs.stat` and `createReadStream` (lines 56–58) are outside any error handler. If the `.dump` file is removed between the `fs.access` check and the subsequent calls (e.g., during a concurrent backup cleanup), `fs.stat` will throw an unhandled exception and Next.js will return a 500 error instead of a graceful 404.

The simpler fix is to drop the redundant `fs.access` check and use `fs.stat` directly inside the try/catch:

```suggestion
  let stat: Awaited<ReturnType<typeof fs.stat>>;
  try {
    stat = await fs.stat(filePath);
  } catch {
    return new Response("Backup not found", { status: 404 });
  }

  const stream = createReadStream(filePath);
  const webStream = Readable.toWeb(stream) as ReadableStream;
```

How can I resolve this? If you propose a fix, please make it concise.


return new Response(webStream, {
headers: {
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="${safe}"`,
"Content-Length": stat.size.toString(),
},
});
}
Loading
Loading