page.tsx104 lines · main
1import { redirect } from 'next/navigation';
2
3import { apiJson } from '../../../lib/api';
4import { allow, deny } from './actions';
5
6/**
7 * `/v1/me` returns a flat profile `{ id, email, ... }` (see apps/api meRouter).
8 * Tolerate a nested `{ user }` shape in case older proxies wrap it.
9 */
10function profileFromMe(data: unknown): { id: string; email: string } | null {
11 if (!data || typeof data !== 'object') return null;
12 const rec = data as Record<string, unknown>;
13 if (typeof rec.id === 'string' && typeof rec.email === 'string') {
14 return { id: rec.id, email: rec.email };
15 }
16 if (rec.user && typeof rec.user === 'object') {
17 const u = rec.user as Record<string, unknown>;
18 if (typeof u.id === 'string' && typeof u.email === 'string') {
19 return { id: u.id, email: u.email };
20 }
21 }
22 return null;
23}
24
25function isLoopbackHttp(url: string): boolean {
26 try {
27 const u = new URL(url);
28 return (
29 u.protocol === 'http:'
30 && (u.hostname === '127.0.0.1' || u.hostname === 'localhost')
31 && u.port.length > 0
32 );
33 } catch {
34 return false;
35 }
36}
37
38export default async function CliAuthPage({
39 searchParams,
40}: {
41 searchParams: Promise<{ redirect?: string; state?: string; host?: string }>;
42}) {
43 const { redirect: redirectUrl, state, host } = await searchParams;
44
45 if (!redirectUrl || !state) {
46 return <ErrorCard reason="missing redirect or state query param" />;
47 }
48 if (!isLoopbackHttp(redirectUrl)) {
49 return <ErrorCard reason="redirect must be a local-loopback http URL" />;
50 }
51 if (state.length > 256) {
52 return <ErrorCard reason="state too long" />;
53 }
54
55 let user: { id: string; email: string } | null = null;
56 try {
57 const data = await apiJson<unknown>('/v1/me');
58 user = profileFromMe(data);
59 } catch {
60 user = null;
61 }
62
63 if (!user) {
64 const back = `/cli-auth?redirect=${encodeURIComponent(redirectUrl)}&state=${encodeURIComponent(state)}${host ? `&host=${encodeURIComponent(host)}` : ''}`;
65 redirect(`/signin?next=${encodeURIComponent(back)}`);
66 }
67
68 return (
69 <div className="flex flex-col gap-4 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-6 font-mono text-sm">
70 <h1 className="text-base">authorize the briven cli?</h1>
71 <p className="text-[var(--color-text-muted)]">
72 signed in as <strong>{user.email}</strong>
73 {host ? ` · machine ${host}` : null}
74 </p>
75 <p className="text-xs text-[var(--color-text-muted)]">
76 issues a 24-hour session token to the cli on your laptop. revoke with{' '}
77 <code>briven logout</code>.
78 </p>
79 <form className="flex gap-2">
80 <button
81 formAction={allow.bind(null, { redirectUrl, state })}
82 className="flex-1 rounded-md bg-[var(--color-primary)] px-3 py-2 text-[var(--color-text-inverse)]"
83 >
84 allow
85 </button>
86 <button
87 formAction={deny.bind(null, { redirectUrl, state })}
88 className="flex-1 rounded-md border border-[var(--color-border)] px-3 py-2 text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
89 >
90 deny
91 </button>
92 </form>
93 </div>
94 );
95}
96
97function ErrorCard({ reason }: { reason: string }) {
98 return (
99 <div className="rounded-md border border-[var(--color-error)] bg-[var(--color-surface)] p-6 font-mono text-sm text-[var(--color-error)]">
100 <h1 className="text-base">this URL was not opened by the briven cli</h1>
101 <p className="mt-2 text-xs">close this tab. ({reason})</p>
102 </div>
103 );
104}