auth-projects-grid.tsx211 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 if (rows.length === 0) {
83 return (
84 <div className="rounded-md border border-dashed border-[var(--color-border)] p-8">
85 <p className="font-mono text-sm text-[var(--color-text)]">
86 no projects yet
87 </p>
88 <p className="mt-1 font-mono text-xs text-[var(--color-text-muted)]">
89 create a project first, then turn Auth on for it here.
90 </p>
91 <Link
92 href="/dashboard/projects/new"
93 className="mt-4 inline-block rounded-md px-3 py-1.5 font-mono text-xs font-medium text-black"
94 style={{ background: 'var(--auth-accent, #FFFD74)' }}
95 >
96 + new project
97 </Link>
98 </div>
99 );
100 }
101
102 return (
103 <div className="flex flex-col gap-3">
104 {err ? (
105 <p className="font-mono text-xs text-red-400">{err}</p>
106 ) : null}
107
108 {rows.length > 5 ? (
109 <div className="flex flex-wrap items-center gap-2">
110 <input
111 type="text"
112 value={q}
113 onChange={(e) => setQ(e.target.value)}
114 placeholder="filter by name / slug / id"
115 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)]"
116 />
117 {q ? (
118 <span className="font-mono text-[10px] text-[var(--color-text-subtle)]">
119 {filtered.length} of {rows.length}
120 </span>
121 ) : null}
122 </div>
123 ) : null}
124
125 {filtered.length === 0 ? (
126 <p className="rounded-md border border-dashed border-[var(--color-border)] p-6 text-center font-mono text-xs text-[var(--color-text-muted)]">
127 no projects match that filter.
128 </p>
129 ) : (
130 <ul className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
131 {filtered.map((p) => (
132 <li
133 key={p.id}
134 className="group relative flex flex-col rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] transition hover:border-[var(--color-border)]"
135 >
136 {p.authEnabled ? (
137 <Link
138 href={`/dashboard/auth/${p.id}`}
139 className="flex flex-1 flex-col gap-1.5 p-4"
140 >
141 <div className="flex items-start justify-between gap-2">
142 <p className="font-mono text-sm text-[var(--color-text)]">
143 {p.name}
144 </p>
145 <AuthBadge on />
146 </div>
147 <p className="font-mono text-xs text-[var(--color-text-subtle)]">
148 {p.slug}
149 {p.tenantId ? (
150 <span className="text-[var(--color-text-muted)]">
151 {' · '}
152 {p.tenantId}
153 </span>
154 ) : null}
155 </p>
156 <span
157 className="mt-auto pt-2 font-mono text-xs"
158 style={{ color: 'var(--auth-accent, #FFFD74)' }}
159 >
160 open Auth →
161 </span>
162 </Link>
163 ) : (
164 <div className="flex flex-1 flex-col gap-1.5 p-4">
165 <div className="flex items-start justify-between gap-2">
166 <p className="font-mono text-sm text-[var(--color-text)]">
167 {p.name}
168 </p>
169 <AuthBadge on={false} />
170 </div>
171 <p className="font-mono text-xs text-[var(--color-text-subtle)]">
172 {p.slug}
173 </p>
174 <div className="mt-auto pt-2">
175 <button
176 type="button"
177 disabled={busyId === p.id}
178 onClick={() => void enable(p.id)}
179 className="rounded-md px-2.5 py-1 font-mono text-[10px] font-medium text-black disabled:opacity-50"
180 style={{ background: 'var(--auth-accent, #FFFD74)' }}
181 >
182 {busyId === p.id ? 'enabling…' : 'enable Auth'}
183 </button>
184 </div>
185 </div>
186 )}
187 </li>
188 ))}
189 </ul>
190 )}
191 </div>
192 );
193}
194
195function AuthBadge({ on }: { on: boolean }) {
196 if (on) {
197 return (
198 <span
199 className="shrink-0 font-mono text-[10px] uppercase tracking-wider"
200 style={{ color: 'var(--auth-accent, #FFFD74)' }}
201 >
202 on
203 </span>
204 );
205 }
206 return (
207 <span className="shrink-0 font-mono text-[10px] uppercase tracking-wider text-[var(--color-text-subtle)]">
208 off
209 </span>
210 );
211}