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 (
+
+
+
+ 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 (
+
+
+
+
+
+
+
+ );
+}
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 (
+
+
+
+
+ {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) => (
+
+
+
+
+ {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}
-
-
- ))}
-
- )}
-
-
-
-
+
+ ))
+ )}
+
+
+
+ {!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
-
-
-
-
+
-
-
- ({
- 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.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.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 },
],
},
{