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
71 changes: 71 additions & 0 deletions app/apps/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { redirect } from "next/navigation";
import { AppShell } from "@/components/AppShell";
import { Section, Row, RowLabel, RowValue, Empty } from "@/components/Section";
import { getCurrentSession } from "@/lib/server/session";
import { listAuthorizationsForUser } from "@/lib/server/repositories/authorizations";
import { revokeAppAction } from "@/app/dashboard-actions";
import { revokeAllOAuthGrantsAction } from "@/app/security/actions";

export const dynamic = "force-dynamic";

function shortDate(value: string | null) {
return value ? value.slice(0, 10) : "never";
}

export default async function ConnectedAppsPage() {
const current = await getCurrentSession();
if (!current) redirect("/login");
const u = current.user;
const apps = await listAuthorizationsForUser(u.id);

return (
<AppShell
user={{ name: u.firstName, username: u.username }}
trail="Connected apps"
isAdmin={u.role === "admin"}
>
<header data-mount-row className="mb-6">
<h1 className="text-[24px] tracking-tight text-fg leading-none mb-1">Connected apps</h1>
<p className="text-[13px] text-muted">External apps with access to your account</p>
</header>

<Section
title="Connected apps"
hint="OAuth grants"
action={
apps.length > 0 ? (
<form action={revokeAllOAuthGrantsAction}>
<button className="text-[13px] text-secondary hover:text-danger transition-colors">
Revoke all
</button>
</form>
) : undefined
}
>
{apps.length === 0 ? (
<Empty>No connected apps</Empty>
) : (
apps.map(app => (
<Row key={app.appSlug}>
<RowLabel>{app.appName}</RowLabel>
<RowValue>
<span className="text-secondary truncate">{app.scopes.join(", ")}</span>
<span className="text-muted">·</span>
<span className="text-muted">Since {shortDate(app.createdAt)}</span>
</RowValue>
<form action={revokeAppAction}>
<input type="hidden" name="appSlug" value={app.appSlug} />
<button
type="submit"
className="text-[13px] text-secondary hover:text-danger transition-colors"
>
Revoke
</button>
</form>
</Row>
))
)}
</Section>
</AppShell>
);
}
40 changes: 40 additions & 0 deletions app/bearers/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { AppShell } from "@/components/AppShell";
import { BearerSection } from "@/components/BearerSection";
import { getCurrentSession } from "@/lib/server/session";
import { listBearerRequestsForUser } from "@/lib/server/repositories/bearerRequests";

export const dynamic = "force-dynamic";

export default async function BearersPage() {
const current = await getCurrentSession();
if (!current) redirect("/login");
const u = current.user;
const bearers = await listBearerRequestsForUser(u.id);

return (
<AppShell
user={{ name: u.firstName, username: u.username }}
trail="API bearers"
isAdmin={u.role === "admin"}
>
<header data-mount-row className="mb-6 flex items-start justify-between gap-4">
<div>
<h1 className="text-[24px] tracking-tight text-fg leading-none mb-1">API bearers</h1>
<p className="text-[13px] text-muted">Long-lived tokens for server-to-server access</p>
</div>
<Link
href="/request-bearer"
className="shrink-0 inline-flex items-center h-9 px-4 rounded-md bg-accent text-fg text-[13px] font-medium hover:brightness-95 transition"
>
Request bearer
</Link>
</header>

<div data-mount-row>
<BearerSection bearers={bearers} />
</div>
</AppShell>
);
}
8 changes: 4 additions & 4 deletions app/dashboard-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export async function revokeSessionAction(formData: FormData) {
if (isNaN(sessionId)) return;

await revokeSessionById(sessionId, current.user.id);
revalidatePath("/");
revalidatePath("/", "layout");
}

export async function revokeAppAction(formData: FormData) {
Expand All @@ -41,7 +41,7 @@ export async function revokeAppAction(formData: FormData) {
await revokeAuthorization(current.user.id, app.id);
await revokeAccessTokensForRefreshGrant({ appId: app.id, userId: current.user.id });
await revokeRefreshTokensByUserAndApp({ appId: app.id, userId: current.user.id });
revalidatePath("/");
revalidatePath("/", "layout");
}

export async function revokePasskeyAction(formData: FormData) {
Expand All @@ -61,7 +61,7 @@ export async function revokePasskeyAction(formData: FormData) {
metadata: { credentialId },
});
}
revalidatePath("/");
revalidatePath("/", "layout");
}

export async function cancelSubscriptionAction(formData: FormData) {
Expand All @@ -72,5 +72,5 @@ export async function cancelSubscriptionAction(formData: FormData) {
if (typeof product !== "string") return;

await cancelSubscription(current.user.id, product);
revalidatePath("/");
revalidatePath("/", "layout");
}
50 changes: 50 additions & 0 deletions app/events/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { redirect } from "next/navigation";
import { AppShell } from "@/components/AppShell";
import { Section, Empty } from "@/components/Section";
import { getCurrentSession } from "@/lib/server/session";
import { recentEventsForUser } from "@/lib/server/repositories/securityEvents";

export const dynamic = "force-dynamic";

export default async function EventsPage() {
const current = await getCurrentSession();
if (!current) redirect("/login");
const u = current.user;
const events = await recentEventsForUser(u.id);

return (
<AppShell
user={{ name: u.firstName, username: u.username }}
trail="Recent events"
isAdmin={u.role === "admin"}
>
<header data-mount-row className="mb-6">
<h1 className="text-[24px] tracking-tight text-fg leading-none mb-1">Recent events</h1>
<p className="text-[13px] text-muted">Security activity on your account</p>
</header>

<Section title="Recent events" hint="Last security activity">
{events.length === 0 ? (
<Empty>No recent events</Empty>
) : (
<ul>
{events.map((event, index) => (
<li
key={`${event.created_at}-${index}`}
className="grid grid-cols-[180px_1fr_auto] gap-4 px-4 py-2.5 items-baseline border-t border-rule first:border-t-0 text-[13px]"
>
<span className="text-muted tabular-nums">
{event.created_at.slice(0, 16).replace("T", " ")}
</span>
<span className="text-fg">{event.event_type}</span>
<span className="text-[12px] text-muted truncate max-w-[280px]">
{event.ip || event.result}
</span>
</li>
))}
</ul>
)}
</Section>
</AppShell>
);
}
Loading