auth-projects-grid.tsx273 lines · main
1'use client';
2
3import Link from 'next/link';
4import { useMemo, useState } from 'react';
5
6import type { AuthV2ProjectRow } from './lib/auth-v2-types';
7
8/**
9 * Auth home — same card-grid language as Projects.
10 * Click a card → that project's Auth (users, sessions, keys, …).
11 */
12export function AuthProjectsGrid({
13 projects,
14}: {
15 projects: AuthV2ProjectRow[];
16}) {
17 const [rows, setRows] = useState(projects);
18 const [q, setQ] = useState('');
19 const [busyId, setBusyId] = useState<string | null>(null);
20 const [err, setErr] = useState<string | null>(null);
21
22 const filtered = useMemo(() => {
23 const needle = q.trim().toLowerCase();
24 const list = !needle
25 ? rows
26 : rows.filter(
27 (p) =>
28 p.name.toLowerCase().includes(needle) ||
29 p.slug.toLowerCase().includes(needle) ||
30 p.id.toLowerCase().includes(needle),
31 );
32 // A → Z by display name (case-insensitive)
33 return [...list].sort((a, b) =>
34 a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }),
35 );
36 }, [rows, q]);
37
38 async function enable(projectId: string): Promise<void> {
39 setBusyId(projectId);
40 setErr(null);
41 try {
42 const res = await fetch(
43 `/api/v1/auth-core/projects/${encodeURIComponent(projectId)}/enable`,
44 {
45 method: 'POST',
46 credentials: 'include',
47 headers: { 'content-type': 'application/json' },
48 },
49 );
50 if (!res.ok) {
51 const body = (await res.json().catch(() => ({}))) as {
52 message?: string;
53 code?: string;
54 };
55 throw new Error(body.message ?? body.code ?? `http ${res.status}`);
56 }
57 setRows((prev) =>
58 prev.map((r) =>
59 r.id === projectId
60 ? {
61 ...r,
62 authEnabled: true,
63 providers: {
64 emailPassword: true,
65 magicLink: true,
66 emailOtp: true,
67 passkey: true,
68 },
69 error: false,
70 }
71 : r,
72 ),
73 );
74 // Full navigation so server workspace re-reads be_tenants (no stale RSC cache).
75 window.location.assign(`/dashboard/auth/${projectId}`);
76 } catch (e) {
77 setErr(e instanceof Error ? e.message : 'enable failed');
78 setBusyId(null);
79 }
80 }
81
82 async function disable(projectId: string, projectName: string): Promise<void> {
83 const ok = window.confirm(
84 `Turn Auth off for “${projectName}”?\n\n` +
85 `Apps using Briven Auth for this project will stop signing people in.\n` +
86 `Your users and settings are kept — you can enable Auth again anytime.`,
87 );
88 if (!ok) return;
89 setBusyId(projectId);
90 setErr(null);
91 try {
92 const res = await fetch(
93 `/api/v1/auth-core/projects/${encodeURIComponent(projectId)}/disable`,
94 {
95 method: 'POST',
96 credentials: 'include',
97 headers: { 'content-type': 'application/json' },
98 },
99 );
100 if (!res.ok) {
101 const body = (await res.json().catch(() => ({}))) as {
102 message?: string;
103 code?: string;
104 };
105 throw new Error(body.message ?? body.code ?? `http ${res.status}`);
106 }
107 setRows((prev) =>
108 prev.map((r) =>
109 r.id === projectId
110 ? {
111 ...r,
112 authEnabled: false,
113 tenantId: null,
114 providers: null,
115 error: false,
116 }
117 : r,
118 ),
119 );
120 // Stay on Auth home so the card shows off + enable again.
121 window.location.assign('/dashboard/auth');
122 } catch (e) {
123 setErr(e instanceof Error ? e.message : 'disable failed');
124 setBusyId(null);
125 }
126 }
127
128 if (rows.length === 0) {
129 return (
130 <div className="rounded-md border border-dashed border-[var(--color-border)] p-8">
131 <p className="font-mono text-sm text-[var(--color-text)]">
132 no projects yet
133 </p>
134 <p className="mt-1 font-mono text-xs text-[var(--color-text-muted)]">
135 create a project first, then turn Auth on for it here.
136 </p>
137 <Link
138 href="/dashboard/projects/new"
139 className="mt-4 inline-block rounded-md px-3 py-1.5 font-mono text-xs font-medium text-black"
140 style={{ background: 'var(--auth-accent, #FFFD74)' }}
141 >
142 + new project
143 </Link>
144 </div>
145 );
146 }
147
148 return (
149 <div className="flex flex-col gap-3">
150 {err ? (
151 <p className="font-mono text-xs text-red-400">{err}</p>
152 ) : null}
153
154 {rows.length > 5 ? (
155 <div className="flex flex-wrap items-center gap-2">
156 <input
157 type="text"
158 value={q}
159 onChange={(e) => setQ(e.target.value)}
160 placeholder="filter by name / slug / id"
161 className="flex-1 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 font-mono text-xs outline-none focus:border-[var(--auth-accent,#FFFD74)]"
162 />
163 {q ? (
164 <span className="font-mono text-[10px] text-[var(--color-text-subtle)]">
165 {filtered.length} of {rows.length}
166 </span>
167 ) : null}
168 </div>
169 ) : null}
170
171 {filtered.length === 0 ? (
172 <p className="rounded-md border border-dashed border-[var(--color-border)] p-6 text-center font-mono text-xs text-[var(--color-text-muted)]">
173 no projects match that filter.
174 </p>
175 ) : (
176 <ul className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
177 {filtered.map((p) => (
178 <li
179 key={p.id}
180 className="group relative flex flex-col rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] transition hover:border-[var(--color-border)]"
181 >
182 {p.authEnabled ? (
183 <div className="flex flex-1 flex-col gap-1.5 p-4">
184 <Link
185 href={`/dashboard/auth/${p.id}`}
186 className="flex flex-1 flex-col gap-1.5"
187 >
188 <div className="flex items-start justify-between gap-2">
189 <p className="font-mono text-sm text-[var(--color-text)]">
190 {p.name}
191 </p>
192 <AuthBadge on />
193 </div>
194 <p className="font-mono text-xs text-[var(--color-text-subtle)]">
195 {p.slug}
196 {p.tenantId ? (
197 <span className="text-[var(--color-text-muted)]">
198 {' · '}
199 {p.tenantId}
200 </span>
201 ) : null}
202 </p>
203 <span
204 className="pt-2 font-mono text-xs"
205 style={{ color: 'var(--auth-accent, #FFFD74)' }}
206 >
207 open Auth →
208 </span>
209 </Link>
210 <div className="mt-2 flex items-center justify-end border-t border-[var(--color-border-subtle)] pt-2">
211 <button
212 type="button"
213 disabled={busyId === p.id}
214 onClick={(e) => {
215 e.preventDefault();
216 e.stopPropagation();
217 void disable(p.id, p.name);
218 }}
219 className="rounded-md border border-[var(--color-border)] px-2.5 py-1 font-mono text-[10px] text-[var(--color-text-muted)] transition hover:border-red-500/40 hover:text-red-300 disabled:opacity-50"
220 >
221 {busyId === p.id ? 'disabling…' : 'disable Auth'}
222 </button>
223 </div>
224 </div>
225 ) : (
226 <div className="flex flex-1 flex-col gap-1.5 p-4">
227 <div className="flex items-start justify-between gap-2">
228 <p className="font-mono text-sm text-[var(--color-text)]">
229 {p.name}
230 </p>
231 <AuthBadge on={false} />
232 </div>
233 <p className="font-mono text-xs text-[var(--color-text-subtle)]">
234 {p.slug}
235 </p>
236 <div className="mt-auto pt-2">
237 <button
238 type="button"
239 disabled={busyId === p.id}
240 onClick={() => void enable(p.id)}
241 className="rounded-md px-2.5 py-1 font-mono text-[10px] font-medium text-black disabled:opacity-50"
242 style={{ background: 'var(--auth-accent, #FFFD74)' }}
243 >
244 {busyId === p.id ? 'enabling…' : 'enable Auth'}
245 </button>
246 </div>
247 </div>
248 )}
249 </li>
250 ))}
251 </ul>
252 )}
253 </div>
254 );
255}
256
257function AuthBadge({ on }: { on: boolean }) {
258 if (on) {
259 return (
260 <span
261 className="shrink-0 font-mono text-[10px] uppercase tracking-wider"
262 style={{ color: 'var(--auth-accent, #FFFD74)' }}
263 >
264 on
265 </span>
266 );
267 }
268 return (
269 <span className="shrink-0 font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]">
270 off
271 </span>
272 );
273}