migration-client.tsx131 lines · main
1'use client';
2
3import { useState } from 'react';
4
5/**
6 * Bulk import users into briven-engine (leave SuperTokens / Clerk).
7 */
8export function AuthMigrationClient({ projectId }: { projectId?: string }) {
9 const [json, setJson] = useState(
10 JSON.stringify(
11 {
12 users: [
13 {
14 email: 'alice@example.com',
15 passwordPlaintext: 'ChangeMe!99',
16 projectId: projectId ?? 'p_…',
17 emailVerified: true,
18 name: 'Alice',
19 },
20 {
21 email: 'bob@example.com',
22 passwordHash:
23 '$2b$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy',
24 hashingAlgorithm: 'bcrypt',
25 projectId: projectId ?? 'p_…',
26 },
27 ],
28 },
29 null,
30 2,
31 ),
32 );
33 const [pending, setPending] = useState(false);
34 const [result, setResult] = useState<string | null>(null);
35 const [err, setErr] = useState<string | null>(null);
36
37 async function run(): Promise<void> {
38 setPending(true);
39 setErr(null);
40 setResult(null);
41 try {
42 const body = JSON.parse(json) as {
43 users?: Array<Record<string, unknown>>;
44 projectId?: string;
45 };
46 if (!Array.isArray(body.users)) throw new Error('JSON must have users: []');
47 // Always stamp this project's id so operators need not paste p_… on every row.
48 if (projectId) {
49 body.projectId = projectId;
50 body.users = body.users.map((u) => ({
51 ...u,
52 projectId: (typeof u.projectId === 'string' && u.projectId) || projectId,
53 }));
54 }
55 const res = await fetch('/api/v1/auth-core/migration/users', {
56 method: 'POST',
57 credentials: 'include',
58 headers: { 'content-type': 'application/json' },
59 body: JSON.stringify(body),
60 });
61 const data = (await res.json().catch(() => ({}))) as {
62 imported?: number;
63 skipped?: number;
64 failed?: number;
65 errors?: Array<{ index: number; message: string }>;
66 message?: string;
67 };
68 if (!res.ok) {
69 throw new Error(data.message ?? `http ${res.status}`);
70 }
71 setResult(
72 `imported ${data.imported ?? 0} · skipped ${data.skipped ?? 0} · failed ${data.failed ?? 0}` +
73 (data.errors?.length
74 ? `\n` +
75 data.errors
76 .slice(0, 5)
77 .map((e) => `#${e.index}: ${e.message}`)
78 .join('\n')
79 : ''),
80 );
81 } catch (e) {
82 setErr(e instanceof Error ? e.message : 'import failed');
83 } finally {
84 setPending(false);
85 }
86 }
87
88 return (
89 <div className="flex max-w-2xl flex-col gap-4">
90 <p className="font-mono text-xs text-[var(--color-text-muted)] leading-relaxed">
91 Paste users from SuperTokens, Clerk, or CSV-turned-JSON. Each row needs{' '}
92 <code className="text-[var(--color-text)]">email</code> plus either{' '}
93 <code className="text-[var(--color-text)]">passwordPlaintext</code> or a{' '}
94 <code className="text-[var(--color-text)]">passwordHash</code> (bcrypt / argon2).
95 Max 500 per request.
96 {projectId ? (
97 <>
98 {' '}
99 This project (<code className="text-[var(--color-text)]">{projectId}</code>) is
100 applied automatically — you can leave projectId out of each row.
101 </>
102 ) : null}
103 </p>
104 <textarea
105 value={json}
106 onChange={(e) => setJson(e.target.value)}
107 rows={16}
108 spellCheck={false}
109 className="rounded-md border bg-[var(--color-surface)] px-3 py-2 font-mono text-[11px] text-[var(--color-text)]"
110 style={{ borderColor: 'var(--auth-accent-border)' }}
111 />
112 <button
113 type="button"
114 disabled={pending}
115 onClick={() => void run()}
116 className="self-start rounded-md px-4 py-2 font-mono text-xs font-medium text-black disabled:opacity-50"
117 style={{ background: '#FFFD74' }}
118 >
119 {pending ? 'importing…' : 'import users'}
120 </button>
121 {result ? (
122 <pre className="whitespace-pre-wrap font-mono text-xs text-[var(--color-text)]">
123 {result}
124 </pre>
125 ) : null}
126 {err ? (
127 <p className="font-mono text-xs text-red-400">{err}</p>
128 ) : null}
129 </div>
130 );
131}