providers-client.tsx1104 lines · main
1'use client';
2
3import { useCallback, useEffect, useMemo, useState } from 'react';
4import { useSearchParams } from 'next/navigation';
5
6import type { AuthV2ProjectRow } from '../lib/auth-v2-types';
7
8type ProviderRow = {
9 thirdPartyId: string;
10 name: string;
11 configured: boolean;
12 hasClientId: boolean;
13 hasClientSecret: boolean;
14 help?: string;
15 callbackHint?: string;
16};
17
18type MethodFlags = {
19 emailPassword: boolean;
20 passwordlessEmail: boolean;
21 magicLink: boolean;
22 passwordlessSms: boolean;
23 passkeys: boolean;
24 mfa: boolean;
25};
26
27type ProjectConfig = {
28 projectId: string;
29 tenantId: string;
30 providers: ProviderRow[];
31 methods?: MethodFlags;
32 delivery: {
33 sms: { configured: boolean };
34 email: { configured: boolean };
35 };
36};
37
38const CORE_METHODS: Array<{
39 key: keyof MethodFlags;
40 label: string;
41 help: string;
42}> = [
43 {
44 key: 'emailPassword',
45 label: 'email + password',
46 help: 'Classic email and password sign-in.',
47 },
48 {
49 key: 'passwordlessEmail',
50 label: 'passwordless-email',
51 help: 'One-time code by email (mittera / SMTP).',
52 },
53 {
54 key: 'magicLink',
55 label: 'magic-link',
56 help: 'Magic link by email — same mail path as OTP.',
57 },
58 {
59 key: 'passwordlessSms',
60 label: 'passwordless-sms',
61 help: 'SMS one-time code — turn on here, then set Twilio below.',
62 },
63 {
64 key: 'passkeys',
65 label: 'passkeys',
66 help: 'WebAuthn passkeys — no client secret.',
67 },
68 {
69 key: 'mfa',
70 label: 'mfa (TOTP)',
71 help: 'Authenticator app after password when enrolled.',
72 },
73];
74
75/**
76 * Providers section = manage ALL authentication ways for this project:
77 * core methods (on/off) + OAuth (Konnos, Google, GitHub…) with secrets.
78 */
79export function AuthProvidersClient({
80 projects,
81 platformMethods: _platformMethods,
82 lockProjectId,
83}: {
84 projects: AuthV2ProjectRow[];
85 platformMethods: string[];
86 lockProjectId?: string;
87}) {
88 const search = useSearchParams();
89 const initialProvider = search.get('provider') ?? 'konnos';
90
91 const [projectId, setProjectId] = useState(
92 lockProjectId ?? projects[0]?.id ?? '',
93 );
94 const [config, setConfig] = useState<ProjectConfig | null>(null);
95 const [methods, setMethods] = useState<MethodFlags | null>(null);
96 /** Which OAuth setup forms are open (multi — stack all of them). */
97 const [openIds, setOpenIds] = useState<string[]>([initialProvider]);
98 /** Per-provider draft secrets while typing. */
99 const [drafts, setDrafts] = useState<
100 Record<string, { clientId: string; clientSecret: string }>
101 >({});
102 const [err, setErr] = useState<string | null>(null);
103 const [okMsg, setOkMsg] = useState<string | null>(null);
104 const [pendingId, setPendingId] = useState<string | null>(null);
105 const [methodPending, setMethodPending] = useState<string | null>(null);
106 /** Twilio-compatible SMS secrets draft (never pre-filled from server). */
107 const [smsDraft, setSmsDraft] = useState({
108 accountSid: '',
109 authToken: '',
110 fromNumber: '',
111 });
112 const [smsPending, setSmsPending] = useState(false);
113 const [testPhone, setTestPhone] = useState('');
114 const [testPending, setTestPending] = useState(false);
115 const [testMsg, setTestMsg] = useState<string | null>(null);
116 const focusSms =
117 search.get('method') === 'passwordlessSms' ||
118 search.get('method') === 'sms';
119
120 const load = useCallback(async (id: string) => {
121 if (!id) return;
122 setErr(null);
123 // Prefer local dashboard proxy (cookies + Origin). Fallback rewrite to
124 // /api/v1/... only if proxy is missing (legacy deploys).
125 const urls = [
126 `/api/dashboard/auth-core/projects/${encodeURIComponent(id)}/config`,
127 `/api/v1/auth-core/projects/${encodeURIComponent(id)}/config`,
128 ];
129 let res: Response | null = null;
130 let lastStatus = 0;
131 for (const url of urls) {
132 try {
133 res = await fetch(url, { credentials: 'include', cache: 'no-store' });
134 lastStatus = res.status;
135 // 404 = wrong path (rewrite ate dashboard). Try next URL.
136 if (res.status === 404) continue;
137 break;
138 } catch {
139 res = null;
140 }
141 }
142 if (!res) {
143 setErr('could not reach auth config');
144 setConfig(null);
145 setMethods(null);
146 return;
147 }
148 if (res.status === 401) {
149 setErr('sign in to briven.tech to manage providers');
150 setConfig(null);
151 setMethods(null);
152 return;
153 }
154 if (res.status === 403) {
155 setErr('you need admin access on this project');
156 setConfig(null);
157 setMethods(null);
158 return;
159 }
160 if (!res.ok) {
161 setErr(`load failed (${lastStatus || res.status})`);
162 setConfig(null);
163 setMethods(null);
164 return;
165 }
166 const body = (await res.json()) as ProjectConfig;
167 setConfig(body);
168 if (body.methods) setMethods(body.methods);
169 else setMethods(null);
170 const ids = body.providers?.map((p) => p.thirdPartyId) ?? [];
171 // Keep open forms; seed first open if empty
172 setOpenIds((prev) => {
173 const kept = prev.filter((x) => ids.includes(x));
174 if (kept.length) return kept;
175 if (initialProvider && ids.includes(initialProvider)) {
176 return [initialProvider];
177 }
178 // Auto-open every already-configured provider so you see them all
179 const configured = (body.providers ?? [])
180 .filter((p) => p.configured)
181 .map((p) => p.thirdPartyId);
182 if (configured.length) return configured;
183 return ids[0] ? [ids[0]] : [];
184 });
185 }, [initialProvider]);
186
187 useEffect(() => {
188 if (projectId) void load(projectId);
189 }, [projectId, load]);
190
191 const apiOrigin =
192 typeof window !== 'undefined'
193 ? (() => {
194 const h = window.location.hostname;
195 if (h === 'briven.tech' || h === 'www.briven.tech') {
196 return 'https://api.briven.tech';
197 }
198 if (h.includes('localhost')) return 'http://localhost:3001';
199 return window.location.origin;
200 })()
201 : 'https://api.briven.tech';
202
203 function callbackFor(p: ProviderRow): string {
204 if (p.callbackHint) {
205 return p.callbackHint
206 .replace('{apiOrigin}', apiOrigin)
207 .replace('{projectId}', projectId)
208 .replace(
209 /^(Redirect URI: |Authorized redirect: |Authorization callback URL: )/i,
210 '',
211 );
212 }
213 return `${apiOrigin}/v1/auth-core/oauth/${p.thirdPartyId}/callback`;
214 }
215
216 function toggleOpen(id: string): void {
217 setOpenIds((prev) => {
218 if (prev.includes(id)) {
219 // Keep at least one form open if possible
220 if (prev.length === 1) return prev;
221 return prev.filter((x) => x !== id);
222 }
223 return [...prev, id];
224 });
225 setOkMsg(null);
226 setErr(null);
227 }
228
229 function setDraft(
230 id: string,
231 field: 'clientId' | 'clientSecret',
232 value: string,
233 ): void {
234 setDrafts((prev) => ({
235 ...prev,
236 [id]: {
237 clientId: prev[id]?.clientId ?? '',
238 clientSecret: prev[id]?.clientSecret ?? '',
239 [field]: value,
240 },
241 }));
242 }
243
244 async function toggleMethod(key: keyof MethodFlags): Promise<void> {
245 if (!projectId || !methods) return;
246 const next = !methods[key];
247 setMethodPending(key);
248 setErr(null);
249 setOkMsg(null);
250 try {
251 const res = await fetch(
252 `/api/dashboard/auth-core/projects/${encodeURIComponent(projectId)}/methods`,
253 {
254 method: 'PUT',
255 credentials: 'include',
256 headers: { 'content-type': 'application/json' },
257 body: JSON.stringify({ [key]: next }),
258 },
259 );
260 const body = (await res.json().catch(() => ({}))) as {
261 message?: string;
262 methods?: MethodFlags;
263 };
264 if (!res.ok) throw new Error(body.message ?? `http ${res.status}`);
265 if (body.methods) setMethods(body.methods);
266 else setMethods((m) => (m ? { ...m, [key]: next } : m));
267 setOkMsg(`${key} ${next ? 'on' : 'off'} for this project`);
268 } catch (e) {
269 setErr(e instanceof Error ? e.message : 'could not update method');
270 } finally {
271 setMethodPending(null);
272 }
273 }
274
275 async function saveSms(): Promise<void> {
276 if (!projectId) return;
277 const accountSid = smsDraft.accountSid.trim();
278 const authToken = smsDraft.authToken.trim();
279 const fromNumber = smsDraft.fromNumber.trim();
280 if (!accountSid || !authToken || !fromNumber) {
281 setErr(
282 'Fill Account SID, Auth token, and From number (like +15551234567), then save.',
283 );
284 return;
285 }
286 if (!fromNumber.startsWith('+')) {
287 setErr('From number must start with + and country code (E.164), e.g. +15551234567.');
288 return;
289 }
290 setSmsPending(true);
291 setErr(null);
292 setOkMsg(null);
293 try {
294 const res = await fetch(
295 `/api/dashboard/auth-core/projects/${encodeURIComponent(projectId)}/delivery/sms`,
296 {
297 method: 'PUT',
298 credentials: 'include',
299 headers: { 'content-type': 'application/json' },
300 body: JSON.stringify({ accountSid, authToken, fromNumber }),
301 },
302 );
303 const rawText = await res.text();
304 let body: {
305 ok?: boolean;
306 message?: string;
307 code?: string;
308 config?: ProjectConfig;
309 } = {};
310 try {
311 body = JSON.parse(rawText) as typeof body;
312 } catch {
313 body = { message: rawText.slice(0, 200) || res.statusText };
314 }
315 if (!res.ok) {
316 throw new Error(
317 body.message ?? body.code ?? `save failed (http ${res.status})`,
318 );
319 }
320 setSmsDraft({ accountSid: '', authToken: '', fromNumber: '' });
321 await load(projectId);
322 if (body.config?.delivery?.sms) {
323 setConfig((prev) =>
324 prev
325 ? {
326 ...prev,
327 delivery: {
328 ...prev.delivery,
329 sms: body.config!.delivery.sms,
330 },
331 }
332 : prev,
333 );
334 } else {
335 setConfig((prev) =>
336 prev
337 ? {
338 ...prev,
339 delivery: {
340 ...prev.delivery,
341 sms: { configured: true },
342 },
343 }
344 : prev,
345 );
346 }
347 setOkMsg(
348 'SMS secrets saved for this project. Turn on passwordless-sms above if it is still off. You can send a test SMS below.',
349 );
350 setTestMsg(null);
351 } catch (e) {
352 setErr(e instanceof Error ? e.message : 'could not save SMS secrets');
353 } finally {
354 setSmsPending(false);
355 }
356 }
357
358 async function sendTestSms(): Promise<void> {
359 if (!projectId) return;
360 const phoneNumber = testPhone.trim();
361 if (!phoneNumber.startsWith('+')) {
362 setTestMsg(null);
363 setErr(
364 'Test phone must start with + and country code (E.164), e.g. +15551234567.',
365 );
366 return;
367 }
368 setTestPending(true);
369 setErr(null);
370 setOkMsg(null);
371 setTestMsg(null);
372 try {
373 const res = await fetch(
374 `/api/dashboard/auth-core/projects/${encodeURIComponent(projectId)}/delivery/sms/test`,
375 {
376 method: 'POST',
377 credentials: 'include',
378 headers: { 'content-type': 'application/json' },
379 body: JSON.stringify({ phoneNumber }),
380 },
381 );
382 const body = (await res.json().catch(() => ({}))) as {
383 ok?: boolean;
384 message?: string;
385 hint?: string;
386 delivery?: { ok?: boolean; mode?: string; message?: string };
387 passwordlessSmsEnabled?: boolean;
388 };
389 if (!res.ok || !body.ok) {
390 const detail =
391 body.delivery?.message ??
392 body.message ??
393 `test failed (http ${res.status})`;
394 throw new Error(detail);
395 }
396 setTestMsg(
397 body.hint ??
398 body.delivery?.message ??
399 'Test SMS sent — check your phone.',
400 );
401 setOkMsg('Test SMS sent. Check your phone.');
402 } catch (e) {
403 setErr(e instanceof Error ? e.message : 'could not send test SMS');
404 } finally {
405 setTestPending(false);
406 }
407 }
408
409 async function saveOauth(providerId: string): Promise<void> {
410 const draft = drafts[providerId];
411 const clientId = draft?.clientId?.trim() ?? '';
412 const clientSecret = draft?.clientSecret?.trim() ?? '';
413 if (!projectId) return;
414 if (!clientId || !clientSecret) {
415 setErr('Enter both client id and client secret, then click save.');
416 return;
417 }
418 setPendingId(providerId);
419 setErr(null);
420 setOkMsg(null);
421 try {
422 const res = await fetch(
423 `/api/dashboard/auth-core/projects/${encodeURIComponent(projectId)}/providers/${encodeURIComponent(providerId)}`,
424 {
425 method: 'PUT',
426 credentials: 'include',
427 headers: { 'content-type': 'application/json' },
428 body: JSON.stringify({ clientId, clientSecret }),
429 },
430 );
431 const rawText = await res.text();
432 let body: {
433 ok?: boolean;
434 message?: string;
435 code?: string;
436 config?: ProjectConfig;
437 } = {};
438 try {
439 body = JSON.parse(rawText) as typeof body;
440 } catch {
441 body = { message: rawText.slice(0, 200) || res.statusText };
442 }
443 if (!res.ok) {
444 throw new Error(
445 body.message ??
446 body.code ??
447 `save failed (http ${res.status})`,
448 );
449 }
450 setDrafts((prev) => ({
451 ...prev,
452 [providerId]: { clientId: '', clientSecret: '' },
453 }));
454 setOpenIds((prev) =>
455 prev.includes(providerId) ? prev : [...prev, providerId],
456 );
457
458 // Always reload from server so Security + chips match DB
459 await load(projectId);
460
461 // Ensure this provider shows as on even if cache lags
462 setConfig((prev) => {
463 if (!prev) return prev;
464 return {
465 ...prev,
466 providers: prev.providers.map((p) =>
467 p.thirdPartyId === providerId
468 ? {
469 ...p,
470 configured: true,
471 hasClientId: true,
472 hasClientSecret: true,
473 }
474 : p,
475 ),
476 };
477 });
478 if (body.config?.methods) setMethods(body.config.methods);
479
480 const name =
481 body.config?.providers?.find((p) => p.thirdPartyId === providerId)
482 ?.name ??
483 config?.providers.find((p) => p.thirdPartyId === providerId)?.name ??
484 providerId;
485 setOkMsg(
486 `${name} saved. Open Security — it should list this OAuth under “OAuth (secrets saved)”.`,
487 );
488 } catch (e) {
489 setErr(e instanceof Error ? e.message : 'save failed');
490 } finally {
491 setPendingId(null);
492 }
493 }
494
495 async function saveAllOpenOauth(): Promise<void> {
496 const toSave = openIds.filter((id) => {
497 const d = drafts[id];
498 return Boolean(d?.clientId?.trim() && d?.clientSecret?.trim());
499 });
500 if (toSave.length === 0) {
501 setErr(
502 'Fill client id + secret on each OAuth form you want saved, then click save (or save open forms).',
503 );
504 return;
505 }
506 setErr(null);
507 const errors: string[] = [];
508 const saved: string[] = [];
509 for (const id of toSave) {
510 const draft = drafts[id];
511 const clientId = draft?.clientId?.trim() ?? '';
512 const clientSecret = draft?.clientSecret?.trim() ?? '';
513 setPendingId(id);
514 try {
515 const res = await fetch(
516 `/api/dashboard/auth-core/projects/${encodeURIComponent(projectId)}/providers/${encodeURIComponent(id)}`,
517 {
518 method: 'PUT',
519 credentials: 'include',
520 headers: { 'content-type': 'application/json' },
521 body: JSON.stringify({ clientId, clientSecret }),
522 },
523 );
524 const body = (await res.json().catch(() => ({}))) as {
525 message?: string;
526 code?: string;
527 };
528 if (!res.ok) {
529 errors.push(
530 `${id}: ${body.message ?? body.code ?? `http ${res.status}`}`,
531 );
532 } else {
533 saved.push(id);
534 setDrafts((prev) => ({
535 ...prev,
536 [id]: { clientId: '', clientSecret: '' },
537 }));
538 }
539 } catch (e) {
540 errors.push(
541 `${id}: ${e instanceof Error ? e.message : 'failed'}`,
542 );
543 }
544 }
545 setPendingId(null);
546 await load(projectId);
547 if (saved.length) {
548 setConfig((prev) => {
549 if (!prev) return prev;
550 return {
551 ...prev,
552 providers: prev.providers.map((p) =>
553 saved.includes(p.thirdPartyId)
554 ? {
555 ...p,
556 configured: true,
557 hasClientId: true,
558 hasClientSecret: true,
559 }
560 : p,
561 ),
562 };
563 });
564 setOkMsg(
565 `Saved: ${saved.join(', ')}. Check Security → OAuth (secrets saved).`,
566 );
567 }
568 if (errors.length) {
569 setErr(errors.join(' · '));
570 }
571 }
572
573 const openProviders = useMemo(() => {
574 if (!config?.providers?.length) return [] as ProviderRow[];
575 // Preserve open order; skip unknown ids
576 const byId = new Map(config.providers.map((p) => [p.thirdPartyId, p]));
577 return openIds
578 .map((id) => byId.get(id))
579 .filter((p): p is ProviderRow => p != null);
580 }, [config, openIds]);
581
582 if (projects.length === 0) {
583 return (
584 <div className="rounded-md border border-dashed border-[var(--color-border)] p-8 font-mono text-sm text-[var(--color-text-muted)]">
585 no projects yet. create a project first.
586 </div>
587 );
588 }
589
590 return (
591 <div className="space-y-8">
592 {!lockProjectId ? (
593 <label className="flex max-w-md flex-col gap-1 font-mono text-xs">
594 <span className="text-[var(--color-text-muted)]">project</span>
595 <select
596 value={projectId}
597 onChange={(e) => setProjectId(e.target.value)}
598 className="rounded-md border bg-[var(--color-surface)] px-3 py-2 text-[var(--color-text)]"
599 style={{ borderColor: 'var(--auth-accent-border)' }}
600 >
601 {projects.map((p) => (
602 <option key={p.id} value={p.id}>
603 {p.name}
604 </option>
605 ))}
606 </select>
607 </label>
608 ) : null}
609
610 {/* ── Sign-in methods for this project ── */}
611 <div className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6">
612 <h2 className="font-mono text-sm text-[var(--color-text)]">
613 sign-in methods
614 </h2>
615 <p className="mt-1 font-mono text-[11px] text-[var(--color-text-muted)]">
616 turn on only what this app should use. yellow = on for this project.
617 </p>
618
619 {methods ? (
620 <ul className="mt-4 space-y-2">
621 {CORE_METHODS.map((m) => {
622 const on = Boolean(methods[m.key]);
623 const smsSecretsOk = Boolean(config?.delivery?.sms?.configured);
624 const smsHint =
625 m.key === 'passwordlessSms'
626 ? on && !smsSecretsOk
627 ? ' · Twilio not set yet'
628 : on && smsSecretsOk
629 ? ' · Twilio ready'
630 : !on && smsSecretsOk
631 ? ' · secrets saved, method off'
632 : ''
633 : '';
634 return (
635 <li
636 key={m.key}
637 className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-[var(--color-border-subtle)] px-3 py-3"
638 >
639 <div className="min-w-0">
640 <p className="font-mono text-xs text-[var(--color-text)]">
641 {m.label}
642 {smsHint ? (
643 <span className="text-[var(--color-text-muted)]">
644 {smsHint}
645 </span>
646 ) : null}
647 </p>
648 <p className="mt-0.5 font-mono text-[10px] text-[var(--color-text-muted)]">
649 {m.help}
650 </p>
651 </div>
652 <button
653 type="button"
654 disabled={methodPending === m.key}
655 onClick={() => void toggleMethod(m.key)}
656 className="shrink-0 rounded-md px-3 py-1.5 font-mono text-[11px] font-medium disabled:opacity-50"
657 style={
658 on
659 ? { background: '#FFFD74', color: '#111' }
660 : {
661 border: '1px solid var(--color-border-subtle)',
662 color: 'var(--color-text-muted)',
663 }
664 }
665 >
666 {methodPending === m.key
667 ? '…'
668 : on
669 ? 'on'
670 : 'off'}
671 </button>
672 </li>
673 );
674 })}
675 </ul>
676 ) : err ? (
677 <p className="mt-3 font-mono text-xs text-red-400">
678 could not load methods — {err}
679 </p>
680 ) : (
681 <p className="mt-3 font-mono text-xs text-[var(--color-text-muted)]">
682 loading methods…
683 </p>
684 )}
685 </div>
686
687 {/* ── SMS / Twilio for this project ── */}
688 <div
689 id="auth-sms-setup"
690 className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6"
691 style={
692 focusSms
693 ? { borderColor: 'var(--auth-accent-border, #FFFD74)' }
694 : undefined
695 }
696 >
697 <div className="flex flex-wrap items-start justify-between gap-3">
698 <div>
699 <h2 className="font-mono text-sm text-[var(--color-text)]">
700 SMS login (Twilio)
701 </h2>
702 <p className="mt-1 font-mono text-[11px] text-[var(--color-text-muted)]">
703 Phone codes for this project only. Secrets stay on Briven — we
704 never show them again after save.
705 </p>
706 </div>
707 <span
708 className="shrink-0 rounded-md px-2.5 py-1 font-mono text-[11px] font-medium"
709 style={
710 config?.delivery?.sms?.configured
711 ? { background: '#FFFD74', color: '#111' }
712 : {
713 border: '1px solid var(--color-border-subtle)',
714 color: 'var(--color-text-muted)',
715 }
716 }
717 >
718 {config
719 ? config.delivery?.sms?.configured
720 ? 'SMS ready'
721 : 'SMS not set'
722 : '…'}
723 </span>
724 </div>
725
726 <ul className="mt-3 list-inside list-disc font-mono text-[11px] text-[var(--color-text-muted)]">
727 <li>Turn on <strong className="text-[var(--color-text)]">passwordless-sms</strong> above.</li>
728 <li>
729 In Twilio: Account SID, Auth Token, and a From number (starts with
730 +).
731 </li>
732 <li>
733 Without secrets, codes are only logged on the server (no real text).
734 </li>
735 </ul>
736
737 {methods?.passwordlessSms && !config?.delivery?.sms?.configured ? (
738 <p className="mt-3 font-mono text-[11px] text-amber-600 dark:text-amber-400">
739 passwordless-sms is on, but Twilio is not set yet — phone login will
740 not reach a real phone until you save secrets below.
741 </p>
742 ) : null}
743
744 {!methods?.passwordlessSms && config?.delivery?.sms?.configured ? (
745 <p className="mt-3 font-mono text-[11px] text-[var(--color-text-muted)]">
746 Twilio is saved. Turn on passwordless-sms above so apps can use SMS
747 login.
748 </p>
749 ) : null}
750
751 <div className="mt-4 flex flex-col gap-3">
752 <label className="flex flex-col gap-1 font-mono text-xs">
753 <span className="text-[var(--color-text-muted)]">Account SID</span>
754 <input
755 value={smsDraft.accountSid}
756 onChange={(e) =>
757 setSmsDraft((d) => ({ ...d, accountSid: e.target.value }))
758 }
759 autoComplete="off"
760 placeholder={
761 config?.delivery?.sms?.configured
762 ? '•••• set — paste new to replace'
763 : 'ACxxxxxxxx…'
764 }
765 className="rounded-md border bg-[var(--color-bg)] px-3 py-2 text-[var(--color-text)] outline-none focus:outline-none"
766 style={{
767 borderColor: 'var(--auth-accent-border, #FFFD74)',
768 }}
769 />
770 </label>
771 <label className="flex flex-col gap-1 font-mono text-xs">
772 <span className="text-[var(--color-text-muted)]">Auth token</span>
773 <input
774 type="password"
775 value={smsDraft.authToken}
776 onChange={(e) =>
777 setSmsDraft((d) => ({ ...d, authToken: e.target.value }))
778 }
779 autoComplete="new-password"
780 placeholder={
781 config?.delivery?.sms?.configured
782 ? '•••• set — paste new to replace'
783 : 'paste Twilio auth token'
784 }
785 className="rounded-md border bg-[var(--color-bg)] px-3 py-2 text-[var(--color-text)] outline-none focus:outline-none"
786 style={{
787 borderColor: 'var(--auth-accent-border, #FFFD74)',
788 }}
789 />
790 </label>
791 <label className="flex flex-col gap-1 font-mono text-xs">
792 <span className="text-[var(--color-text-muted)]">
793 From number (E.164)
794 </span>
795 <input
796 value={smsDraft.fromNumber}
797 onChange={(e) =>
798 setSmsDraft((d) => ({ ...d, fromNumber: e.target.value }))
799 }
800 autoComplete="off"
801 placeholder={
802 config?.delivery?.sms?.configured
803 ? '•••• set — paste new to replace'
804 : '+15551234567'
805 }
806 className="rounded-md border bg-[var(--color-bg)] px-3 py-2 text-[var(--color-text)] outline-none focus:outline-none"
807 style={{
808 borderColor: 'var(--auth-accent-border, #FFFD74)',
809 }}
810 />
811 </label>
812 <button
813 type="button"
814 disabled={
815 smsPending ||
816 !smsDraft.accountSid.trim() ||
817 !smsDraft.authToken.trim() ||
818 !smsDraft.fromNumber.trim()
819 }
820 onClick={() => void saveSms()}
821 className="w-fit rounded-md px-4 py-2 font-mono text-xs font-medium text-black disabled:opacity-50"
822 style={{ background: '#FFFD74' }}
823 >
824 {smsPending ? 'saving…' : 'save SMS secrets'}
825 </button>
826 </div>
827
828 {config?.delivery?.sms?.configured ? (
829 <div
830 className="mt-5 space-y-3 border-t pt-4"
831 style={{ borderColor: 'var(--color-border-subtle)' }}
832 >
833 <p className="font-mono text-xs text-[var(--color-text)]">
834 send test SMS
835 </p>
836 <p className="font-mono text-[10px] text-[var(--color-text-muted)]">
837 Uses saved Twilio secrets. Message says this is a test — not a
838 login code. Real texts may cost Twilio credit.
839 </p>
840 <div className="flex flex-col gap-2 sm:flex-row sm:items-end">
841 <label className="flex min-w-[12rem] flex-1 flex-col gap-1 font-mono text-xs">
842 <span className="text-[var(--color-text-muted)]">
843 your phone (E.164)
844 </span>
845 <input
846 value={testPhone}
847 onChange={(e) => setTestPhone(e.target.value)}
848 autoComplete="tel"
849 placeholder="+15551234567"
850 className="rounded-md border bg-[var(--color-bg)] px-3 py-2 text-[var(--color-text)] outline-none focus:outline-none"
851 style={{
852 borderColor: 'var(--auth-accent-border, #FFFD74)',
853 }}
854 />
855 </label>
856 <button
857 type="button"
858 disabled={testPending || !testPhone.trim()}
859 onClick={() => void sendTestSms()}
860 className="rounded-md px-4 py-2 font-mono text-xs font-medium text-black disabled:opacity-50"
861 style={{ background: '#FFFD74' }}
862 >
863 {testPending ? 'sending…' : 'send test SMS'}
864 </button>
865 </div>
866 {testMsg ? (
867 <p className="font-mono text-[11px] text-[var(--color-text-muted)]">
868 {testMsg}
869 </p>
870 ) : null}
871 </div>
872 ) : (
873 <p className="mt-4 font-mono text-[10px] text-[var(--color-text-muted)]">
874 After secrets show as <strong className="text-[var(--color-text)]">SMS ready</strong>, a
875 “send test SMS” box appears here.
876 </p>
877 )}
878 </div>
879
880 {/* ── OAuth providers ── */}
881 <div className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6">
882 <h2 className="font-mono text-sm text-[var(--color-text)]">
883 OAuth providers
884 </h2>
885 <p className="mt-1 font-mono text-[11px] text-[var(--color-text-muted)]">
886 Click chips to open <strong className="text-[var(--color-text)]">several</strong>{' '}
887 forms at once (stacked below). Yellow chip = secrets already saved.
888 Click again to hide a form (not delete secrets).
889 </p>
890
891 {config ? (
892 <>
893 <p className="mt-3 font-mono text-[11px] text-[var(--color-text-muted)]">
894 tenant {config.tenantId}
895 {config.providers.some((p) => p.configured)
896 ? ` · saved: ${config.providers
897 .filter((p) => p.configured)
898 .map((p) => p.name)
899 .join(', ')}`
900 : ' · none saved yet'}
901 </p>
902
903 <div className="mt-3 flex flex-wrap items-center gap-2">
904 <ul className="flex flex-wrap gap-2">
905 {config.providers.map((p) => {
906 const isOpen = openIds.includes(p.thirdPartyId);
907 const isOn = p.configured;
908 return (
909 <li key={p.thirdPartyId}>
910 <button
911 type="button"
912 title={
913 isOpen
914 ? `Hide ${p.name} form`
915 : `Show ${p.name} client id + secret form`
916 }
917 onClick={() => toggleOpen(p.thirdPartyId)}
918 className="rounded border px-2.5 py-1.5 font-mono text-[11px] outline-none focus:outline-none"
919 style={
920 isOn
921 ? {
922 borderColor: '#FFFD74',
923 background: '#FFFD74',
924 color: '#111',
925 boxShadow: isOpen
926 ? '0 0 0 2px #111, 0 0 0 4px #FFFD74'
927 : undefined,
928 }
929 : {
930 borderColor: isOpen
931 ? '#FFFD74'
932 : 'var(--color-border-subtle)',
933 background: isOpen
934 ? 'color-mix(in srgb, #FFFD74 14%, transparent)'
935 : 'transparent',
936 color: 'var(--color-text-muted)',
937 }
938 }
939 >
940 {p.name}
941 {isOn ? ' · on' : ' · set up'}
942 {isOpen ? ' · open' : ''}
943 </button>
944 </li>
945 );
946 })}
947 </ul>
948 <button
949 type="button"
950 onClick={() => void saveAllOpenOauth()}
951 className="ml-auto rounded-md px-3 py-1.5 font-mono text-[11px] font-medium text-black"
952 style={{ background: '#FFFD74' }}
953 >
954 save open forms
955 </button>
956 </div>
957
958 {/* One credential card per open provider — stacked, never replace */}
959 <div className="mt-5 space-y-4">
960 {openProviders.map((p) => {
961 const draft = drafts[p.thirdPartyId] ?? {
962 clientId: '',
963 clientSecret: '',
964 };
965 const pending = pendingId === p.thirdPartyId;
966 return (
967 <div
968 key={p.thirdPartyId}
969 className="space-y-3 rounded-md border p-4"
970 style={{
971 borderColor: 'var(--auth-accent-border, #FFFD74)',
972 }}
973 >
974 <div className="flex flex-wrap items-center justify-between gap-2">
975 <p className="font-mono text-xs text-[var(--color-text)]">
976 {p.name} — client id &amp; secret
977 {p.configured ? (
978 <span className="ml-2 text-[var(--color-text-muted)]">
979 (saved)
980 </span>
981 ) : null}
982 </p>
983 <button
984 type="button"
985 onClick={() => toggleOpen(p.thirdPartyId)}
986 className="font-mono text-[10px] text-[var(--color-text-muted)] underline"
987 >
988 hide
989 </button>
990 </div>
991 {p.help ? (
992 <p className="font-mono text-[11px] text-[var(--color-text-muted)]">
993 {p.help}
994 </p>
995 ) : null}
996
997 <label className="flex flex-col gap-1 font-mono text-xs">
998 <span className="text-[var(--color-text-muted)]">
999 redirect / callback URL (copy into provider console)
1000 </span>
1001 <code className="break-all rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-bg)] px-3 py-2 text-[11px] text-[var(--color-text)]">
1002 {callbackFor(p)}
1003 </code>
1004 </label>
1005
1006 <div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-end">
1007 <label className="flex min-w-[10rem] flex-1 flex-col gap-1 font-mono text-xs">
1008 <span className="text-[var(--color-text-muted)]">
1009 client id
1010 </span>
1011 <input
1012 value={draft.clientId}
1013 onChange={(e) =>
1014 setDraft(p.thirdPartyId, 'clientId', e.target.value)
1015 }
1016 autoComplete="off"
1017 placeholder={
1018 p.hasClientId
1019 ? '•••• set — paste new to replace'
1020 : `paste ${p.name} client id`
1021 }
1022 className="rounded-md border bg-[var(--color-bg)] px-3 py-2 text-[var(--color-text)] outline-none focus:outline-none"
1023 style={{
1024 borderColor: 'var(--auth-accent-border, #FFFD74)',
1025 }}
1026 />
1027 </label>
1028 <label className="flex min-w-[10rem] flex-1 flex-col gap-1 font-mono text-xs">
1029 <span className="text-[var(--color-text-muted)]">
1030 client secret
1031 </span>
1032 <input
1033 type="password"
1034 value={draft.clientSecret}
1035 onChange={(e) =>
1036 setDraft(
1037 p.thirdPartyId,
1038 'clientSecret',
1039 e.target.value,
1040 )
1041 }
1042 autoComplete="new-password"
1043 placeholder={
1044 p.hasClientSecret
1045 ? '•••• set — paste new to replace'
1046 : `paste ${p.name} client secret`
1047 }
1048 className="rounded-md border bg-[var(--color-bg)] px-3 py-2 text-[var(--color-text)] outline-none focus:outline-none"
1049 style={{
1050 borderColor: 'var(--auth-accent-border, #FFFD74)',
1051 }}
1052 />
1053 </label>
1054 <button
1055 type="button"
1056 disabled={
1057 pending ||
1058 !draft.clientId.trim() ||
1059 !draft.clientSecret.trim()
1060 }
1061 onClick={() => void saveOauth(p.thirdPartyId)}
1062 className="rounded-md px-4 py-2 font-mono text-xs font-medium text-black disabled:opacity-50"
1063 style={{ background: '#FFFD74' }}
1064 >
1065 {pending ? 'saving…' : `save ${p.name}`}
1066 </button>
1067 </div>
1068 {p.configured ? (
1069 <p className="font-mono text-[11px] text-[var(--color-text-muted)]">
1070 {p.name} is configured for this project
1071 </p>
1072 ) : (
1073 <p className="font-mono text-[11px] text-[var(--color-text-muted)]">
1074 not set yet — paste secrets from the {p.name} developer
1075 console
1076 </p>
1077 )}
1078 </div>
1079 );
1080 })}
1081 </div>
1082 </>
1083 ) : err ? (
1084 <p className="mt-3 font-mono text-xs text-red-400">
1085 could not load OAuth list — {err}
1086 </p>
1087 ) : (
1088 <p className="mt-3 font-mono text-xs text-[var(--color-text-muted)]">
1089 loading providers…
1090 </p>
1091 )}
1092 </div>
1093
1094 {err ? (
1095 <p className="font-mono text-xs text-red-400">{err}</p>
1096 ) : null}
1097 {okMsg ? (
1098 <p className="font-mono text-xs text-[var(--color-text-muted)]">
1099 {okMsg}
1100 </p>
1101 ) : null}
1102 </div>
1103 );
1104}