From c71910453413b51e6f7e7931999c4a070acba297 Mon Sep 17 00:00:00 2001 From: Matthew Demidoff Date: Sat, 20 Jun 2026 01:25:04 +0200 Subject: [PATCH] feat(ui): real per-section routes for the account area Replace the single-page account dashboard (sidebar items were anchor links that just scrolled) with a real route per nav item: /profile, /subscriptions, /apps, /bearers, /sessions, /events, and a refocused /security (password, Telegram 2FA, passkeys). The home route becomes an Account home overview - a card grid (Account, Security, Access summaries) plus Recent activity and Next steps. Each sub-page fetches only its slice; server actions revalidate across the new routes. --- app/apps/page.tsx | 71 ++++++ app/bearers/page.tsx | 40 ++++ app/dashboard-actions.ts | 8 +- app/events/page.tsx | 50 +++++ app/page.tsx | 447 ++++++++++--------------------------- app/profile/page.tsx | 50 +++++ app/security/actions.ts | 6 +- app/security/page.tsx | 260 +++++---------------- app/sessions/page.tsx | 68 ++++++ app/subscriptions/page.tsx | 63 ++++++ components/AppShell.tsx | 18 +- 11 files changed, 525 insertions(+), 556 deletions(-) create mode 100644 app/apps/page.tsx create mode 100644 app/bearers/page.tsx create mode 100644 app/events/page.tsx create mode 100644 app/profile/page.tsx create mode 100644 app/sessions/page.tsx create mode 100644 app/subscriptions/page.tsx diff --git a/app/apps/page.tsx b/app/apps/page.tsx new file mode 100644 index 0000000..13b88b4 --- /dev/null +++ b/app/apps/page.tsx @@ -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 ( + +
+

Connected apps

+

External apps with access to your account

+
+ +
0 ? ( +
+ +
+ ) : undefined + } + > + {apps.length === 0 ? ( + No connected apps + ) : ( + apps.map(app => ( + + {app.appName} + + {app.scopes.join(", ")} + · + Since {shortDate(app.createdAt)} + +
+ + +
+
+ )) + )} +
+
+ ); +} diff --git a/app/bearers/page.tsx b/app/bearers/page.tsx new file mode 100644 index 0000000..cdf14a4 --- /dev/null +++ b/app/bearers/page.tsx @@ -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 ( + +
+
+

API bearers

+

Long-lived tokens for server-to-server access

+
+ + Request bearer + +
+ +
+ +
+
+ ); +} diff --git a/app/dashboard-actions.ts b/app/dashboard-actions.ts index 0e4de38..aacea52 100644 --- a/app/dashboard-actions.ts +++ b/app/dashboard-actions.ts @@ -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) { @@ -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) { @@ -61,7 +61,7 @@ export async function revokePasskeyAction(formData: FormData) { metadata: { credentialId }, }); } - revalidatePath("/"); + revalidatePath("/", "layout"); } export async function cancelSubscriptionAction(formData: FormData) { @@ -72,5 +72,5 @@ export async function cancelSubscriptionAction(formData: FormData) { if (typeof product !== "string") return; await cancelSubscription(current.user.id, product); - revalidatePath("/"); + revalidatePath("/", "layout"); } diff --git a/app/events/page.tsx b/app/events/page.tsx new file mode 100644 index 0000000..2092eda --- /dev/null +++ b/app/events/page.tsx @@ -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 ( + +
+

Recent events

+

Security activity on your account

+
+ +
+ {events.length === 0 ? ( + No recent events + ) : ( +
    + {events.map((event, index) => ( +
  • + + {event.created_at.slice(0, 16).replace("T", " ")} + + {event.event_type} + + {event.ip || event.result} + +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index 4075091..6c30dea 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,357 +1,146 @@ +import Link from "next/link"; import { redirect } from "next/navigation"; -import { BearerSection } from "@/components/BearerSection"; +import { ArrowRight } from "lucide-react"; import { AppShell } from "@/components/AppShell"; -import { Section, Row, RowLabel, RowValue, Empty } from "@/components/Section"; import { Tag } from "@/components/Tag"; import { getCurrentSession } from "@/lib/server/session"; import { getDashboard } from "@/lib/server/services/dashboard"; -import { - revokeSessionAction, - revokeAppAction, - cancelSubscriptionAction, -} from "./dashboard-actions"; import { findWebauthnCredentialsByUser } from "@/lib/server/repositories/webauthn"; -import { PasskeyManager } from "@/components/PasskeyManager"; export const dynamic = "force-dynamic"; function statusTone(status: string) { - return status === "active" - ? "success" - : status === "banned" - ? "danger" - : "warning"; + return status === "active" ? "success" : status === "banned" ? "danger" : "warning"; } function shortDate(value: string | null) { - if (!value) return "never"; - return value.slice(0, 10); + return value ? value.slice(0, 10) : "never"; } -export default async function DashboardPage() { - const current = await getCurrentSession(); - if (!current) { - redirect("/login"); - } +function Card({ + title, + href, + children, +}: { + title: string; + href?: string; + children: React.ReactNode; +}) { + return ( +
+
+

{title}

+ {href && ( + + View + + )} +
+
{children}
+
+ ); +} + +function CardRow({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+ {label} + {value} +
+ ); +} - const dashboard = await getDashboard(current.user); - const account = dashboard.account; - const webauthnCredentials = await findWebauthnCredentialsByUser( - current.user.id, +function NextStep({ href, label }: { href: string; label: string }) { + return ( + + {label} + + ); +} + +export default async function AccountHomePage() { + const current = await getCurrentSession(); + if (!current) redirect("/login"); - const stats = [ - { label: "subscriptions", value: dashboard.stats.subscriptions, hint: "active" }, - { label: "apps", value: dashboard.stats.apps, hint: "connected" }, - { label: "sessions", value: dashboard.stats.sessions, hint: "active" }, - { label: "activations", value: dashboard.stats.activations, hint: "recent" }, - ]; + const u = current.user; + const [dashboard, passkeys] = await Promise.all([ + getDashboard(u), + findWebauthnCredentialsByUser(u.id), + ]); + const { stats, events, apps, sessions } = dashboard; + const hasTelegram = !!u.telegramVerifiedAt; return ( -
-
-

- {account.firstName} -

- / - - @{account.username} - -
-
- {account.status} - - {account.telegramVerifiedAt ? "Telegram verified" : "Telegram unverified"} - -
-
- -
- {stats.map((stat, i) => ( +
+

Account home

+
+ + Signed in as {u.firstName} (@{u.username}) + + {u.status} + + {hasTelegram ? "Telegram verified" : "Telegram unverified"} + +
+
+ +
+ + {u.status}} /> + + + + + + + + + + + + + +
+ +
+ + {events.length === 0 ? ( +
No recent events
+ ) : ( + events.slice(0, 6).map((event, i) => (
0 ? "md:border-l border-rule" : ""}`} + key={`${event.created_at}-${i}`} + className="flex items-center justify-between gap-3 px-4 py-2.5 border-t border-rule first:border-t-0 text-[13px]" > -
- {stat.label.charAt(0).toUpperCase() + stat.label.slice(1)} -
-
- - {String(stat.value).padStart(2, "0")} - - {stat.hint} -
-
- ))} -
- -
-
-
- - First name - {account.firstName} - - - - Username - @{account.username} - - - - Bio - {account.bio || "Not set"} - - Public + {event.event_type} + + {event.created_at.slice(0, 10)} - - - Email - {account.email} - - - - Date of birth - {account.dob || "Not set"} - - - - Telegram - - {account.telegramUsername - ? `@${account.telegramUsername}` - : account.telegramId || "Not linked"} - - - Relink - - -
-
- -
-
-
- {dashboard.subscriptions.length === 0 ? ( - No active subscriptions - ) : ( - dashboard.subscriptions.map(subscription => ( - - {subscription.product} - - - {subscription.status} - - · - - Expires {shortDate(subscription.expiresAt)} - - -
- - -
-
- )) - )} -
-
- -
-
-
- {dashboard.apps.length === 0 ? ( - No connected apps - ) : ( - dashboard.apps.map(app => ( - - {app.appName} - - - {app.scopes.join(", ")} - - · - - Since {shortDate(app.createdAt)} - - -
- - -
-
- )) - )} -
-
- -
-
- -
- -
-
-
- {dashboard.sessions.map(session => ( - - {session.userAgent || "Unknown browser"} - - - {session.ip || "Unknown IP"} - - · - - {shortDate(session.lastSeenAt)} - - {session.id === current.session.id && ( - This device - )} - -
- - -
-
- ))} -
-
- -
-
-
- - Password - Enabled - - - - Telegram 2FA - - {account.telegramVerifiedAt - ? `Enabled ${shortDate(account.telegramVerifiedAt)}` - : "Not linked"} - - - Relink - - -
-
- -
-
-
- ({ - id: c.credentialId, - name: c.name || "Unknown Device", - lastUsed: c.lastUsedAt, - }))} - /> -
-
- -
-
-
- {dashboard.events.length === 0 ? ( - No recent events - ) : ( -
    - {dashboard.events.map((event, index) => ( -
  • - - {event.created_at.slice(0, 16).replace("T", " ")} - - {event.event_type} - - {event.ip || event.result} - -
  • - ))} -
- )} -
-
- -
- bottleneck - auth.bneck.com -
+
+ )) + )} + + + + {!hasTelegram && } + {passkeys.length === 0 && } + {apps.length > 0 && } + {sessions.length > 1 && } + + +
); } diff --git a/app/profile/page.tsx b/app/profile/page.tsx new file mode 100644 index 0000000..321c057 --- /dev/null +++ b/app/profile/page.tsx @@ -0,0 +1,50 @@ +import { redirect } from "next/navigation"; +import { AppShell } from "@/components/AppShell"; +import { Section, Row, RowLabel, RowValue } from "@/components/Section"; +import { getCurrentSession } from "@/lib/server/session"; + +export const dynamic = "force-dynamic"; + +export default async function ProfilePage() { + const current = await getCurrentSession(); + if (!current) redirect("/login"); + const u = current.user; + + return ( + +
+

Profile

+

Your account details

+
+ +
+ First name{u.firstName} + Username@{u.username} + + Bio + {u.bio || "Not set"} + Public + + Email{u.email} + + Date of birth + {u.dob || "Not set"} + + + + Telegram + + {u.telegramUsername ? `@${u.telegramUsername}` : u.telegramId || "Not linked"} + + + Relink + + +
+
+ ); +} diff --git a/app/security/actions.ts b/app/security/actions.ts index 8e15ede..e3f33f1 100644 --- a/app/security/actions.ts +++ b/app/security/actions.ts @@ -26,8 +26,7 @@ export async function revokeOtherSessionsAction() { result: "revoked_other_sessions", context: requestContextFromHeaders(await headers()), }); - revalidatePath("/security"); - revalidatePath("/"); + revalidatePath("/", "layout"); } // CSRF is covered by Next.js server-action origin verification plus the @@ -77,6 +76,5 @@ export async function revokeAllOAuthGrantsAction() { result: "revoked_all_grants", context: requestContextFromHeaders(await headers()), }); - revalidatePath("/security"); - revalidatePath("/"); + revalidatePath("/", "layout"); } diff --git a/app/security/page.tsx b/app/security/page.tsx index e4d589e..f333329 100644 --- a/app/security/page.tsx +++ b/app/security/page.tsx @@ -1,232 +1,72 @@ import Link from "next/link"; import { redirect } from "next/navigation"; +import { AppShell } from "@/components/AppShell"; import { PasskeyManager } from "@/components/PasskeyManager"; import { ChangePasswordForm } from "@/components/ChangePasswordForm"; -import { Section, Row, RowLabel, RowValue, Empty } from "@/components/Section"; -import { AppShell } from "@/components/AppShell"; -import { Tag } from "@/components/Tag"; -import { findWebauthnCredentialsByUser } from "@/lib/server/repositories/webauthn"; +import { Section, Row, RowLabel, RowValue } from "@/components/Section"; import { getCurrentSession } from "@/lib/server/session"; -import { getDashboard } from "@/lib/server/services/dashboard"; -import { - changePasswordAction, - revokeAllOAuthGrantsAction, - revokeOtherSessionsAction, -} from "./actions"; +import { findWebauthnCredentialsByUser } from "@/lib/server/repositories/webauthn"; +import { changePasswordAction } from "./actions"; export const dynamic = "force-dynamic"; function shortDate(value: string | null) { - return value ? value.slice(0, 16).replace("T", " ") : "never"; + return value ? value.slice(0, 10) : "never"; } -export default async function SecurityCenterPage() { +export default async function SecurityPage() { const current = await getCurrentSession(); - if (!current) { - redirect("/login?next=/security"); - } - - const [dashboard, passkeys] = await Promise.all([ - getDashboard(current.user), - findWebauthnCredentialsByUser(current.user.id), - ]); - - const hasTelegram = Boolean(current.user.telegramVerifiedAt); + if (!current) redirect("/login"); + const u = current.user; + const passkeys = await findWebauthnCredentialsByUser(u.id); + const hasTelegram = !!u.telegramVerifiedAt; return ( -
-
- - {hasTelegram ? "2FA enabled" : "2FA missing"} - -
-

- Security center -

-

- Sessions, OAuth grants, passkeys, Telegram 2FA, and recent - security activity. -

-
- -
- {[ - { label: "Sessions", value: dashboard.sessions.length }, - { label: "OAuth grants", value: dashboard.apps.length }, - { label: "Passkeys", value: passkeys.length }, - { label: "Events", value: dashboard.events.length }, - ].map((item, i) => ( -
0 ? "border-l border-rule" : "" - }`} - > -
- {item.label} -
-
- {String(item.value).padStart(2, "0")} -
-
- ))} -
- -
-
- - - } - > - {dashboard.sessions.map(session => ( - - {session.userAgent || "Unknown browser"} - - - {session.ip || "Unknown IP"} - - · - - {shortDate(session.lastSeenAt)} - - {session.id === current.session.id && ( - This device - )} - - - - ))} -
-
- -
-
- - - } - > - {dashboard.apps.length === 0 ? ( - No connected apps - ) : ( - dashboard.apps.map(app => ( - - {app.appName} - {app.scopes.join(", ")} - - {shortDate(app.createdAt)} - - - )) - )} -
-
- -
-
- - Status - - {hasTelegram ? ( - - - - Enabled {shortDate(current.user.telegramVerifiedAt)} - - - ) : ( - - - Not linked - - )} - - - Relink - - -
-
+
+

Password & 2FA

+

Authentication and account recovery

+
-
-
- ({ - id: item.credentialId, - name: item.name || "Unknown Device", - lastUsed: item.lastUsedAt, - }))} - /> -
-
+
+ +
-
-
- -
-
+
+ + Status + + {hasTelegram ? ( + + + Enabled {shortDate(u.telegramVerifiedAt)} + + ) : ( + + + Not linked + + )} + + + Relink + + +
-
-
- {dashboard.events.length === 0 ? ( - No recent events - ) : ( - dashboard.events.map((event, index) => ( - - - - {shortDate(event.created_at)} - - - {event.event_type} - - {event.result} - - - )) - )} -
-
+
+ ({ + id: item.credentialId, + name: item.name || "Unknown Device", + lastUsed: item.lastUsedAt, + }))} + /> +
); } diff --git a/app/sessions/page.tsx b/app/sessions/page.tsx new file mode 100644 index 0000000..7a92b65 --- /dev/null +++ b/app/sessions/page.tsx @@ -0,0 +1,68 @@ +import { redirect } from "next/navigation"; +import { AppShell } from "@/components/AppShell"; +import { Section, Row, RowLabel, RowValue } from "@/components/Section"; +import { Tag } from "@/components/Tag"; +import { getCurrentSession } from "@/lib/server/session"; +import { listSessionsForUser } from "@/lib/server/repositories/sessions"; +import { revokeSessionAction } from "@/app/dashboard-actions"; +import { revokeOtherSessionsAction } 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 SessionsPage() { + const current = await getCurrentSession(); + if (!current) redirect("/login"); + const u = current.user; + const sessions = await listSessionsForUser(u.id); + + return ( + +
+

Sessions

+

Devices currently signed in

+
+ +
+ + + } + > + {sessions.map(session => ( + + {session.userAgent || "Unknown browser"} + + {session.ip || "Unknown IP"} + · + {shortDate(session.lastSeenAt)} + {session.id === current.session.id && This device} + +
+ + +
+
+ ))} +
+
+ ); +} diff --git a/app/subscriptions/page.tsx b/app/subscriptions/page.tsx new file mode 100644 index 0000000..a616084 --- /dev/null +++ b/app/subscriptions/page.tsx @@ -0,0 +1,63 @@ +import { redirect } from "next/navigation"; +import { AppShell } from "@/components/AppShell"; +import { Section, Row, RowLabel, RowValue, Empty } from "@/components/Section"; +import { Tag } from "@/components/Tag"; +import { getCurrentSession } from "@/lib/server/session"; +import { listSubscriptionsForUser } from "@/lib/server/repositories/subscriptions"; +import { cancelSubscriptionAction } from "@/app/dashboard-actions"; + +export const dynamic = "force-dynamic"; + +function shortDate(value: string | null) { + return value ? value.slice(0, 10) : "never"; +} + +export default async function SubscriptionsPage() { + const current = await getCurrentSession(); + if (!current) redirect("/login"); + const u = current.user; + const subscriptions = await listSubscriptionsForUser(u.id); + + return ( + +
+

Subscriptions

+

Products tied to your account

+
+ +
+ {subscriptions.length === 0 ? ( + No active subscriptions + ) : ( + subscriptions.map(subscription => ( + + {subscription.product} + + + {subscription.status} + + · + + Expires {shortDate(subscription.expiresAt)} + + +
+ + +
+
+ )) + )} +
+
+ ); +} diff --git a/components/AppShell.tsx b/components/AppShell.tsx index 5099c10..d52e5da 100644 --- a/components/AppShell.tsx +++ b/components/AppShell.tsx @@ -11,7 +11,7 @@ import { MonitorSmartphone, ShieldCheck, History, - Shield, + House, Boxes, BookOpen, FlaskConical, @@ -39,19 +39,19 @@ const USER_NAV: NavGroup[] = [ { label: "Account", items: [ - { href: "/#profile", label: "Profile", icon: User }, - { href: "/#subscriptions", label: "Subscriptions", icon: CreditCard }, - { href: "/#apps", label: "Connected apps", icon: LayoutGrid }, - { href: "/#bearers", label: "API bearers", icon: KeyRound }, + { href: "/", label: "Account home", icon: House }, + { href: "/profile", label: "Profile", icon: User }, + { href: "/subscriptions", label: "Subscriptions", icon: CreditCard }, + { href: "/apps", label: "Connected apps", icon: LayoutGrid }, + { href: "/bearers", label: "API bearers", icon: KeyRound }, ], }, { label: "Security", items: [ - { href: "/#sessions", label: "Sessions", icon: MonitorSmartphone }, - { href: "/#security", label: "Password & 2FA", icon: ShieldCheck }, - { href: "/#events", label: "Recent events", icon: History }, - { href: "/security", label: "Security center", icon: Shield }, + { href: "/sessions", label: "Sessions", icon: MonitorSmartphone }, + { href: "/security", label: "Password & 2FA", icon: ShieldCheck }, + { href: "/events", label: "Recent events", icon: History }, ], }, {