providers-client.tsx1213 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 revokeOauth(providerId: string, providerName: string): Promise<void> {
496 if (!projectId) return;
497 const ok = window.confirm(
498 `Revoke ${providerName} OAuth for this project?\n\n` +
499 `Client ID and client secret will be deleted. The fields go empty again. ` +
500 `Apps using this provider will stop signing in until you paste new secrets.`,
501 );
502 if (!ok) return;
503 setPendingId(`revoke:${providerId}`);
504 setErr(null);
505 setOkMsg(null);
506 try {
507 const res = await fetch(
508 `/api/dashboard/auth-core/projects/${encodeURIComponent(projectId)}/providers/${encodeURIComponent(providerId)}`,
509 { method: 'DELETE', credentials: 'include' },
510 );
511 const body = (await res.json().catch(() => ({}))) as {
512 message?: string;
513 code?: string;
514 config?: ProjectConfig;
515 };
516 if (!res.ok) {
517 throw new Error(body.message ?? body.code ?? `http ${res.status}`);
518 }
519 setDrafts((prev) => ({
520 ...prev,
521 [providerId]: { clientId: '', clientSecret: '' },
522 }));
523 await load(projectId);
524 setConfig((prev) => {
525 if (!prev) return prev;
526 return {
527 ...prev,
528 providers: prev.providers.map((p) =>
529 p.thirdPartyId === providerId
530 ? {
531 ...p,
532 configured: false,
533 hasClientId: false,
534 hasClientSecret: false,
535 }
536 : p,
537 ),
538 };
539 });
540 if (body.config) setConfig(body.config);
541 setOkMsg(
542 `${providerName} revoked — client id and secret deleted. Paste new secrets to enable again.`,
543 );
544 } catch (e) {
545 setErr(e instanceof Error ? e.message : 'revoke failed');
546 } finally {
547 setPendingId(null);
548 }
549 }
550
551 async function saveAllOpenOauth(): Promise<void> {
552 const toSave = openIds.filter((id) => {
553 const d = drafts[id];
554 return Boolean(d?.clientId?.trim() && d?.clientSecret?.trim());
555 });
556 if (toSave.length === 0) {
557 setErr(
558 'Fill client id + secret on each OAuth form you want saved, then click save (or save open forms).',
559 );
560 return;
561 }
562 setErr(null);
563 const errors: string[] = [];
564 const saved: string[] = [];
565 for (const id of toSave) {
566 const draft = drafts[id];
567 const clientId = draft?.clientId?.trim() ?? '';
568 const clientSecret = draft?.clientSecret?.trim() ?? '';
569 setPendingId(id);
570 try {
571 const res = await fetch(
572 `/api/dashboard/auth-core/projects/${encodeURIComponent(projectId)}/providers/${encodeURIComponent(id)}`,
573 {
574 method: 'PUT',
575 credentials: 'include',
576 headers: { 'content-type': 'application/json' },
577 body: JSON.stringify({ clientId, clientSecret }),
578 },
579 );
580 const body = (await res.json().catch(() => ({}))) as {
581 message?: string;
582 code?: string;
583 };
584 if (!res.ok) {
585 errors.push(
586 `${id}: ${body.message ?? body.code ?? `http ${res.status}`}`,
587 );
588 } else {
589 saved.push(id);
590 setDrafts((prev) => ({
591 ...prev,
592 [id]: { clientId: '', clientSecret: '' },
593 }));
594 }
595 } catch (e) {
596 errors.push(
597 `${id}: ${e instanceof Error ? e.message : 'failed'}`,
598 );
599 }
600 }
601 setPendingId(null);
602 await load(projectId);
603 if (saved.length) {
604 setConfig((prev) => {
605 if (!prev) return prev;
606 return {
607 ...prev,
608 providers: prev.providers.map((p) =>
609 saved.includes(p.thirdPartyId)
610 ? {
611 ...p,
612 configured: true,
613 hasClientId: true,
614 hasClientSecret: true,
615 }
616 : p,
617 ),
618 };
619 });
620 setOkMsg(
621 `Saved: ${saved.join(', ')}. Check Security → OAuth (secrets saved).`,
622 );
623 }
624 if (errors.length) {
625 setErr(errors.join(' · '));
626 }
627 }
628
629 const openProviders = useMemo(() => {
630 if (!config?.providers?.length) return [] as ProviderRow[];
631 // Preserve open order; skip unknown ids
632 const byId = new Map(config.providers.map((p) => [p.thirdPartyId, p]));
633 return openIds
634 .map((id) => byId.get(id))
635 .filter((p): p is ProviderRow => p != null);
636 }, [config, openIds]);
637
638 if (projects.length === 0) {
639 return (
640 <div className="rounded-md border border-dashed border-[var(--color-border)] p-8 font-mono text-sm text-[var(--color-text-muted)]">
641 no projects yet. create a project first.
642 </div>
643 );
644 }
645
646 return (
647 <div className="space-y-8">
648 {!lockProjectId ? (
649 <label className="flex max-w-md flex-col gap-1 font-mono text-xs">
650 <span className="text-[var(--color-text-muted)]">project</span>
651 <select
652 value={projectId}
653 onChange={(e) => setProjectId(e.target.value)}
654 className="rounded-md border bg-[var(--color-surface)] px-3 py-2 text-[var(--color-text)]"
655 style={{ borderColor: 'var(--auth-accent-border)' }}
656 >
657 {projects.map((p) => (
658 <option key={p.id} value={p.id}>
659 {p.name}
660 </option>
661 ))}
662 </select>
663 </label>
664 ) : null}
665
666 {/* ── Sign-in methods for this project ── */}
667 <div className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6">
668 <h2 className="font-mono text-sm text-[var(--color-text)]">
669 sign-in methods
670 </h2>
671 <p className="mt-1 font-mono text-[11px] text-[var(--color-text-muted)]">
672 turn on only what this app should use. yellow = on for this project.
673 </p>
674
675 {methods ? (
676 <ul className="mt-4 space-y-2">
677 {CORE_METHODS.map((m) => {
678 const on = Boolean(methods[m.key]);
679 const smsSecretsOk = Boolean(config?.delivery?.sms?.configured);
680 const smsHint =
681 m.key === 'passwordlessSms'
682 ? on && !smsSecretsOk
683 ? ' · Twilio not set yet'
684 : on && smsSecretsOk
685 ? ' · Twilio ready'
686 : !on && smsSecretsOk
687 ? ' · secrets saved, method off'
688 : ''
689 : '';
690 return (
691 <li
692 key={m.key}
693 className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-[var(--color-border-subtle)] px-3 py-3"
694 >
695 <div className="min-w-0">
696 <p className="font-mono text-xs text-[var(--color-text)]">
697 {m.label}
698 {smsHint ? (
699 <span className="text-[var(--color-text-muted)]">
700 {smsHint}
701 </span>
702 ) : null}
703 </p>
704 <p className="mt-0.5 font-mono text-[10px] text-[var(--color-text-muted)]">
705 {m.help}
706 </p>
707 </div>
708 <button
709 type="button"
710 disabled={methodPending === m.key}
711 onClick={() => void toggleMethod(m.key)}
712 className="shrink-0 rounded-md px-3 py-1.5 font-mono text-[11px] font-medium disabled:opacity-50"
713 style={
714 on
715 ? { background: '#FFFD74', color: '#111' }
716 : {
717 border: '1px solid var(--color-border-subtle)',
718 color: 'var(--color-text-muted)',
719 }
720 }
721 >
722 {methodPending === m.key
723 ? '…'
724 : on
725 ? 'on'
726 : 'off'}
727 </button>
728 </li>
729 );
730 })}
731 </ul>
732 ) : err ? (
733 <p className="mt-3 font-mono text-xs text-red-400">
734 could not load methods — {err}
735 </p>
736 ) : (
737 <p className="mt-3 font-mono text-xs text-[var(--color-text-muted)]">
738 loading methods…
739 </p>
740 )}
741 </div>
742
743 {/* ── SMS / Twilio for this project ── */}
744 <div
745 id="auth-sms-setup"
746 className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6"
747 style={
748 focusSms
749 ? { borderColor: 'var(--auth-accent-border, #FFFD74)' }
750 : undefined
751 }
752 >
753 <div className="flex flex-wrap items-start justify-between gap-3">
754 <div>
755 <h2 className="font-mono text-sm text-[var(--color-text)]">
756 SMS login (Twilio)
757 </h2>
758 <p className="mt-1 font-mono text-[11px] text-[var(--color-text-muted)]">
759 Phone codes for this project only. Secrets stay on Briven — we
760 never show them again after save.
761 </p>
762 </div>
763 <span
764 className="shrink-0 rounded-md px-2.5 py-1 font-mono text-[11px] font-medium"
765 style={
766 config?.delivery?.sms?.configured
767 ? { background: '#FFFD74', color: '#111' }
768 : {
769 border: '1px solid var(--color-border-subtle)',
770 color: 'var(--color-text-muted)',
771 }
772 }
773 >
774 {config
775 ? config.delivery?.sms?.configured
776 ? 'SMS ready'
777 : 'SMS not set'
778 : '…'}
779 </span>
780 </div>
781
782 <ul className="mt-3 list-inside list-disc font-mono text-[11px] text-[var(--color-text-muted)]">
783 <li>Turn on <strong className="text-[var(--color-text)]">passwordless-sms</strong> above.</li>
784 <li>
785 In Twilio: Account SID, Auth Token, and a From number (starts with
786 +).
787 </li>
788 <li>
789 Without secrets, codes are only logged on the server (no real text).
790 </li>
791 </ul>
792
793 {methods?.passwordlessSms && !config?.delivery?.sms?.configured ? (
794 <p className="mt-3 font-mono text-[11px] text-amber-600 dark:text-amber-400">
795 passwordless-sms is on, but Twilio is not set yet — phone login will
796 not reach a real phone until you save secrets below.
797 </p>
798 ) : null}
799
800 {!methods?.passwordlessSms && config?.delivery?.sms?.configured ? (
801 <p className="mt-3 font-mono text-[11px] text-[var(--color-text-muted)]">
802 Twilio is saved. Turn on passwordless-sms above so apps can use SMS
803 login.
804 </p>
805 ) : null}
806
807 <div className="mt-4 flex flex-col gap-3">
808 <label className="flex flex-col gap-1 font-mono text-xs">
809 <span className="text-[var(--color-text-muted)]">Account SID</span>
810 <input
811 value={smsDraft.accountSid}
812 onChange={(e) =>
813 setSmsDraft((d) => ({ ...d, accountSid: e.target.value }))
814 }
815 autoComplete="off"
816 placeholder={
817 config?.delivery?.sms?.configured
818 ? '•••• set — paste new to replace'
819 : 'ACxxxxxxxx…'
820 }
821 className="rounded-md border bg-[var(--color-bg)] px-3 py-2 text-[var(--color-text)] outline-none focus:outline-none"
822 style={{
823 borderColor: 'var(--auth-accent-border, #FFFD74)',
824 }}
825 />
826 </label>
827 <label className="flex flex-col gap-1 font-mono text-xs">
828 <span className="text-[var(--color-text-muted)]">Auth token</span>
829 <input
830 type="password"
831 value={smsDraft.authToken}
832 onChange={(e) =>
833 setSmsDraft((d) => ({ ...d, authToken: e.target.value }))
834 }
835 autoComplete="new-password"
836 placeholder={
837 config?.delivery?.sms?.configured
838 ? '•••• set — paste new to replace'
839 : 'paste Twilio auth token'
840 }
841 className="rounded-md border bg-[var(--color-bg)] px-3 py-2 text-[var(--color-text)] outline-none focus:outline-none"
842 style={{
843 borderColor: 'var(--auth-accent-border, #FFFD74)',
844 }}
845 />
846 </label>
847 <label className="flex flex-col gap-1 font-mono text-xs">
848 <span className="text-[var(--color-text-muted)]">
849 From number (E.164)
850 </span>
851 <input
852 value={smsDraft.fromNumber}
853 onChange={(e) =>
854 setSmsDraft((d) => ({ ...d, fromNumber: e.target.value }))
855 }
856 autoComplete="off"
857 placeholder={
858 config?.delivery?.sms?.configured
859 ? '•••• set — paste new to replace'
860 : '+15551234567'
861 }
862 className="rounded-md border bg-[var(--color-bg)] px-3 py-2 text-[var(--color-text)] outline-none focus:outline-none"
863 style={{
864 borderColor: 'var(--auth-accent-border, #FFFD74)',
865 }}
866 />
867 </label>
868 <button
869 type="button"
870 disabled={
871 smsPending ||
872 !smsDraft.accountSid.trim() ||
873 !smsDraft.authToken.trim() ||
874 !smsDraft.fromNumber.trim()
875 }
876 onClick={() => void saveSms()}
877 className="w-fit rounded-md px-4 py-2 font-mono text-xs font-medium text-black disabled:opacity-50"
878 style={{ background: '#FFFD74' }}
879 >
880 {smsPending ? 'saving…' : 'save SMS secrets'}
881 </button>
882 </div>
883
884 {config?.delivery?.sms?.configured ? (
885 <div
886 className="mt-5 space-y-3 border-t pt-4"
887 style={{ borderColor: 'var(--color-border-subtle)' }}
888 >
889 <p className="font-mono text-xs text-[var(--color-text)]">
890 send test SMS
891 </p>
892 <p className="font-mono text-[10px] text-[var(--color-text-muted)]">
893 Uses saved Twilio secrets. Message says this is a test — not a
894 login code. Real texts may cost Twilio credit.
895 </p>
896 <div className="flex flex-col gap-2 sm:flex-row sm:items-end">
897 <label className="flex min-w-[12rem] flex-1 flex-col gap-1 font-mono text-xs">
898 <span className="text-[var(--color-text-muted)]">
899 your phone (E.164)
900 </span>
901 <input
902 value={testPhone}
903 onChange={(e) => setTestPhone(e.target.value)}
904 autoComplete="tel"
905 placeholder="+15551234567"
906 className="rounded-md border bg-[var(--color-bg)] px-3 py-2 text-[var(--color-text)] outline-none focus:outline-none"
907 style={{
908 borderColor: 'var(--auth-accent-border, #FFFD74)',
909 }}
910 />
911 </label>
912 <button
913 type="button"
914 disabled={testPending || !testPhone.trim()}
915 onClick={() => void sendTestSms()}
916 className="rounded-md px-4 py-2 font-mono text-xs font-medium text-black disabled:opacity-50"
917 style={{ background: '#FFFD74' }}
918 >
919 {testPending ? 'sending…' : 'send test SMS'}
920 </button>
921 </div>
922 {testMsg ? (
923 <p className="font-mono text-[11px] text-[var(--color-text-muted)]">
924 {testMsg}
925 </p>
926 ) : null}
927 </div>
928 ) : (
929 <p className="mt-4 font-mono text-[10px] text-[var(--color-text-muted)]">
930 After secrets show as <strong className="text-[var(--color-text)]">SMS ready</strong>, a
931 “send test SMS” box appears here.
932 </p>
933 )}
934 </div>
935
936 {/* ── OAuth providers ── */}
937 <div className="rounded-md border border-[var(--color-border-subtle)] bg-[var(--color-surface)] p-6">
938 <h2 className="font-mono text-sm text-[var(--color-text)]">
939 OAuth providers
940 </h2>
941 <p className="mt-1 font-mono text-[11px] text-[var(--color-text-muted)]">
942 Click chips to open <strong className="text-[var(--color-text)]">several</strong>{' '}
943 forms at once (stacked below). Yellow chip = secrets already saved.
944 Click again to hide a form (not delete secrets).
945 </p>
946
947 {config ? (
948 <>
949 <p className="mt-3 font-mono text-[11px] text-[var(--color-text-muted)]">
950 tenant {config.tenantId}
951 {config.providers.some((p) => p.configured)
952 ? ` · saved: ${config.providers
953 .filter((p) => p.configured)
954 .map((p) => p.name)
955 .join(', ')}`
956 : ' · none saved yet'}
957 </p>
958
959 <div className="mt-3 flex flex-wrap items-center gap-2">
960 <ul className="flex flex-wrap gap-2">
961 {config.providers.map((p) => {
962 const isOpen = openIds.includes(p.thirdPartyId);
963 const isOn = p.configured;
964 return (
965 <li key={p.thirdPartyId}>
966 <button
967 type="button"
968 title={
969 isOpen
970 ? `Hide ${p.name} form`
971 : `Show ${p.name} client id + secret form`
972 }
973 onClick={() => toggleOpen(p.thirdPartyId)}
974 className="rounded border px-2.5 py-1.5 font-mono text-[11px] outline-none focus:outline-none"
975 style={
976 isOn
977 ? {
978 borderColor: '#FFFD74',
979 background: '#FFFD74',
980 color: '#111',
981 boxShadow: isOpen
982 ? '0 0 0 2px #111, 0 0 0 4px #FFFD74'
983 : undefined,
984 }
985 : {
986 borderColor: isOpen
987 ? '#FFFD74'
988 : 'var(--color-border-subtle)',
989 background: isOpen
990 ? 'color-mix(in srgb, #FFFD74 14%, transparent)'
991 : 'transparent',
992 color: 'var(--color-text-muted)',
993 }
994 }
995 >
996 {p.thirdPartyId === 'konnos' ? (
997 // eslint-disable-next-line @next/next/no-img-element -- static mark
998 <img
999 src="/konnos.svg"
1000 alt=""
1001 width={14}
1002 height={14}
1003 className="mr-1.5 inline-block h-3.5 w-3.5 align-[-2px] object-contain"
1004 aria-hidden
1005 />
1006 ) : null}
1007 {p.name}
1008 {isOn ? ' · on' : ' · set up'}
1009 {isOpen ? ' · open' : ''}
1010 </button>
1011 </li>
1012 );
1013 })}
1014 </ul>
1015 <button
1016 type="button"
1017 onClick={() => void saveAllOpenOauth()}
1018 className="ml-auto rounded-md px-3 py-1.5 font-mono text-[11px] font-medium text-black"
1019 style={{ background: '#FFFD74' }}
1020 >
1021 save open forms
1022 </button>
1023 </div>
1024
1025 {/* One credential card per open provider — stacked, never replace */}
1026 <div className="mt-5 space-y-4">
1027 {openProviders.map((p) => {
1028 const draft = drafts[p.thirdPartyId] ?? {
1029 clientId: '',
1030 clientSecret: '',
1031 };
1032 const pending =
1033 pendingId === p.thirdPartyId ||
1034 pendingId === `revoke:${p.thirdPartyId}`;
1035 return (
1036 <div
1037 key={p.thirdPartyId}
1038 className="space-y-3 rounded-md border p-4"
1039 style={{
1040 borderColor: 'var(--auth-accent-border, #FFFD74)',
1041 }}
1042 >
1043 <div className="flex flex-wrap items-center justify-between gap-2">
1044 <p className="font-mono text-xs text-[var(--color-text)]">
1045 {p.thirdPartyId === 'konnos' ? (
1046 // eslint-disable-next-line @next/next/no-img-element -- static mark
1047 <img
1048 src="/konnos.svg"
1049 alt=""
1050 width={16}
1051 height={16}
1052 className="mr-1.5 inline-block h-4 w-4 align-[-3px] object-contain"
1053 aria-hidden
1054 />
1055 ) : null}
1056 {p.name} — client id &amp; secret
1057 {p.configured ? (
1058 <span className="ml-2 text-[var(--color-text-muted)]">
1059 (saved)
1060 </span>
1061 ) : null}
1062 </p>
1063 <button
1064 type="button"
1065 onClick={() => toggleOpen(p.thirdPartyId)}
1066 className="font-mono text-[10px] text-[var(--color-text-muted)] underline"
1067 >
1068 hide
1069 </button>
1070 </div>
1071 {p.help ? (
1072 <p className="font-mono text-[11px] text-[var(--color-text-muted)]">
1073 {p.help}
1074 </p>
1075 ) : null}
1076
1077 <label className="flex flex-col gap-1 font-mono text-xs">
1078 <span className="text-[var(--color-text-muted)]">
1079 redirect / callback URL (copy into provider console —
1080 must match exactly)
1081 </span>
1082 <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)]">
1083 {callbackFor(p)}
1084 </code>
1085 {p.thirdPartyId === 'konnos' ? (
1086 <p className="font-mono text-[10px] text-[var(--color-text-muted)]">
1087 SuperTokens-style: redirect_uri is the OAuth callback, not
1088 your post-login page. mavi pay:{' '}
1089 <span className="text-[var(--color-text)]">
1090 https://pay.mavifinans.sh/auth/callback
1091 </span>
1092 . Local:{' '}
1093 <span className="text-[var(--color-text)]">
1094 http://localhost:3000/auth/callback
1095 </span>
1096 . Must match the Konnos app field character-for-character.
1097 </p>
1098 ) : null}
1099 </label>
1100
1101 <div className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-end">
1102 <label className="flex min-w-[10rem] flex-1 flex-col gap-1 font-mono text-xs">
1103 <span className="text-[var(--color-text-muted)]">
1104 client id
1105 </span>
1106 <input
1107 value={draft.clientId}
1108 onChange={(e) =>
1109 setDraft(p.thirdPartyId, 'clientId', e.target.value)
1110 }
1111 autoComplete="off"
1112 placeholder={
1113 p.hasClientId
1114 ? '•••• set — paste new to replace'
1115 : `paste ${p.name} client id`
1116 }
1117 className="rounded-md border bg-[var(--color-bg)] px-3 py-2 text-[var(--color-text)] outline-none focus:outline-none"
1118 style={{
1119 borderColor: 'var(--auth-accent-border, #FFFD74)',
1120 }}
1121 />
1122 </label>
1123 <label className="flex min-w-[10rem] flex-1 flex-col gap-1 font-mono text-xs">
1124 <span className="text-[var(--color-text-muted)]">
1125 client secret
1126 </span>
1127 <input
1128 type="password"
1129 value={draft.clientSecret}
1130 onChange={(e) =>
1131 setDraft(
1132 p.thirdPartyId,
1133 'clientSecret',
1134 e.target.value,
1135 )
1136 }
1137 autoComplete="new-password"
1138 placeholder={
1139 p.hasClientSecret
1140 ? '•••• set — paste new to replace'
1141 : `paste ${p.name} client secret`
1142 }
1143 className="rounded-md border bg-[var(--color-bg)] px-3 py-2 text-[var(--color-text)] outline-none focus:outline-none"
1144 style={{
1145 borderColor: 'var(--auth-accent-border, #FFFD74)',
1146 }}
1147 />
1148 </label>
1149 <button
1150 type="button"
1151 disabled={
1152 pending ||
1153 !draft.clientId.trim() ||
1154 !draft.clientSecret.trim()
1155 }
1156 onClick={() => void saveOauth(p.thirdPartyId)}
1157 className="rounded-md px-4 py-2 font-mono text-xs font-medium text-black disabled:opacity-50"
1158 style={{ background: '#FFFD74' }}
1159 >
1160 {pending && pendingId === p.thirdPartyId
1161 ? 'saving…'
1162 : `save ${p.name}`}
1163 </button>
1164 {p.configured || p.hasClientId || p.hasClientSecret ? (
1165 <button
1166 type="button"
1167 disabled={pending}
1168 onClick={() => void revokeOauth(p.thirdPartyId, p.name)}
1169 className="rounded-md border border-[var(--color-border)] px-4 py-2 font-mono text-xs text-[var(--color-text-muted)] transition hover:border-red-500/40 hover:text-red-300 disabled:opacity-50"
1170 >
1171 {pendingId === `revoke:${p.thirdPartyId}`
1172 ? 'revoking…'
1173 : `revoke ${p.name}`}
1174 </button>
1175 ) : null}
1176 </div>
1177 {p.configured ? (
1178 <p className="font-mono text-[11px] text-[var(--color-text-muted)]">
1179 {p.name} is configured for this project
1180 </p>
1181 ) : (
1182 <p className="font-mono text-[11px] text-[var(--color-text-muted)]">
1183 not set yet — paste secrets from the {p.name} developer
1184 console
1185 </p>
1186 )}
1187 </div>
1188 );
1189 })}
1190 </div>
1191 </>
1192 ) : err ? (
1193 <p className="mt-3 font-mono text-xs text-red-400">
1194 could not load OAuth list — {err}
1195 </p>
1196 ) : (
1197 <p className="mt-3 font-mono text-xs text-[var(--color-text-muted)]">
1198 loading providers…
1199 </p>
1200 )}
1201 </div>
1202
1203 {err ? (
1204 <p className="font-mono text-xs text-red-400">{err}</p>
1205 ) : null}
1206 {okMsg ? (
1207 <p className="font-mono text-xs text-[var(--color-text-muted)]">
1208 {okMsg}
1209 </p>
1210 ) : null}
1211 </div>
1212 );
1213}