eny.space Landingpage
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(pds): separate direct user creation from invite flow

+188 -165
+51 -42
app/api/pds/atproto/create-account/route.ts
··· 7 7 getPdsServiceForCurrentUser, 8 8 } from "../helpers"; 9 9 10 + function toBasicAuth(user: string, pass: string) { 11 + return `Basic ${Buffer.from(`${user}:${pass}`).toString("base64")}`; 12 + } 13 + 14 + async function generateInviteCode(pdsBaseUrl: string, authHeader: string): Promise<string> { 15 + const res = await fetch(`${pdsBaseUrl}/xrpc/com.atproto.server.createInviteCode`, { 16 + method: "POST", 17 + headers: { "Content-Type": "application/json", Authorization: authHeader }, 18 + body: JSON.stringify({ useCount: 1 }), 19 + }); 20 + if (!res.ok) { 21 + throw new Error(`Failed to generate invite code (${res.status})`); 22 + } 23 + const data = await res.json(); 24 + const code = data?.code || data?.inviteCode; 25 + if (!code) throw new Error("Invite code missing from response"); 26 + return code; 27 + } 28 + 10 29 export async function POST(req: Request) { 11 30 try { 12 31 const body = (await req.json()) as { 13 32 email?: string; 14 33 handle: string; 15 34 password: string; 16 - inviteCode: string; 17 35 }; 18 36 19 - if (!body?.handle || !body?.password || !body?.inviteCode) { 37 + if (!body?.handle || !body?.password) { 20 38 return NextResponse.json( 21 - { message: "Missing required fields: handle, password, inviteCode" }, 22 - { status: 400 } 39 + { message: "Missing required fields: handle, password" }, 40 + { status: 400 }, 23 41 ); 24 42 } 25 43 ··· 30 48 const requiredServiceId = Number(requiredServiceIdRaw); 31 49 if (pdsServiceId !== requiredServiceId) { 32 50 return NextResponse.json( 33 - { 34 - message: `PDS service id mismatch: expected ${requiredServiceId}, got ${pdsServiceId}`, 35 - }, 36 - { status: 409 } 51 + { message: `PDS service id mismatch: expected ${requiredServiceId}, got ${pdsServiceId}` }, 52 + { status: 409 }, 37 53 ); 38 54 } 39 55 } 40 56 41 - // Ensure we always have `https://...` for fetch 57 + const adminPassword = service?.encrypted_config?.adminPassword as string | undefined; 58 + if (!adminPassword) { 59 + return NextResponse.json( 60 + { message: "Missing PDS admin credentials" }, 61 + { status: 500 }, 62 + ); 63 + } 64 + 42 65 const pdsBaseUrl = getPdsBaseUrlFromService(service); 66 + const authHeader = toBasicAuth("admin", String(adminPassword).trim()); 43 67 44 68 let emailToUse = body.email; 45 69 if (!emailToUse) { 46 - // Reuse the user's own Supabase email for testing, but add a +alias suffix 47 - // to avoid collisions if the backend enforces uniqueness. 48 70 const supabase = await createClient(); 49 - const { 50 - data: { user }, 51 - } = await supabase.auth.getUser(); 52 - 71 + const { data: { user } } = await supabase.auth.getUser(); 53 72 if (!user?.email) { 54 73 return NextResponse.json( 55 - { 56 - message: 57 - "Missing email (neither request body nor Supabase user email found)", 58 - }, 59 - { status: 400 } 74 + { message: "Missing email: provide one in the request or ensure a Supabase user is logged in" }, 75 + { status: 400 }, 60 76 ); 61 77 } 62 - 63 - const baseEmail = user.email; 64 - const [local, domain] = baseEmail.split("@"); 65 - const alias = `${local}+atproto-test-${Date.now()}`; 66 - emailToUse = `${alias}@${domain}`; 78 + const [local, domain] = user.email.split("@"); 79 + emailToUse = `${local}+pds-${Date.now()}@${domain}`; 67 80 } 68 81 69 - const res = await fetch( 70 - `${pdsBaseUrl}/xrpc/com.atproto.server.createAccount`, 71 - { 72 - method: "POST", 73 - headers: { 74 - "Content-Type": "application/json", 75 - }, 76 - body: JSON.stringify({ 77 - email: emailToUse, 78 - handle: body.handle, 79 - password: body.password, 80 - inviteCode: body.inviteCode, 81 - }), 82 - } 83 - ); 82 + const inviteCode = await generateInviteCode(pdsBaseUrl, authHeader); 83 + 84 + const res = await fetch(`${pdsBaseUrl}/xrpc/com.atproto.server.createAccount`, { 85 + method: "POST", 86 + headers: { "Content-Type": "application/json" }, 87 + body: JSON.stringify({ 88 + email: emailToUse, 89 + handle: body.handle, 90 + password: body.password, 91 + inviteCode, 92 + }), 93 + }); 84 94 85 95 const contentType = res.headers.get("content-type") || ""; 86 96 const payload = contentType.includes("application/json") ··· 90 100 if (!res.ok) { 91 101 return NextResponse.json( 92 102 { message: "Failed to create account", status: res.status, payload }, 93 - { status: 502 } 103 + { status: 502 }, 94 104 ); 95 105 } 96 106 97 - // Return the email that we used, so the UI mirrors the real admin workflow. 98 107 if (payload && typeof payload === "object") { 99 108 return NextResponse.json({ ...(payload as any), emailUsed: emailToUse }); 100 109 }
+137 -123
app/dashboard/user-dashboard-client.tsx
··· 9 9 type ServiceResponse = { 10 10 hostname?: string; 11 11 encrypted_config?: { hostname?: string }; 12 - state?: number | string; 13 12 }; 14 13 15 14 function stripScheme(hostname?: string) { ··· 17 16 return hostname.replace(/^https?:\/\//i, "").replace(/\/.*$/, ""); 18 17 } 19 18 19 + async function apiCall(path: string, body: unknown) { 20 + const res = await fetch(path, { 21 + method: "POST", 22 + headers: { "Content-Type": "application/json" }, 23 + body: JSON.stringify(body), 24 + }); 25 + const payload = await res.json().catch(() => ({})); 26 + if (!res.ok) { 27 + throw new Error(payload?.message || payload?.payload?.message || "Request failed"); 28 + } 29 + return payload; 30 + } 31 + 20 32 export function UserDashboardClient() { 21 - const [pdsHost, setPdsHost] = useState<string>(""); 22 - const [inviteCode, setInviteCode] = useState<string>(""); 23 - const [handle, setHandle] = useState<string>(""); 24 - const [password, setPassword] = useState<string>(""); 25 - const [loading, setLoading] = useState(false); 33 + const [pdsBareHost, setPdsBareHost] = useState<string>(""); 26 34 const [fetchError, setFetchError] = useState<string | null>(null); 27 - const [actionError, setActionError] = useState<string | null>(null); 28 - const [actionSuccess, setActionSuccess] = useState<string | null>(null); 29 - 30 - const pdsBareHost = useMemo(() => stripScheme(pdsHost), [pdsHost]); 31 - const fullHandle = useMemo( 32 - () => (handle && pdsBareHost ? `${handle}.${pdsBareHost}` : ""), 33 - [handle, pdsBareHost], 34 - ); 35 35 36 36 useEffect(() => { 37 37 fetch("/api/pds/service") 38 38 .then((r) => r.json()) 39 39 .then((data: ServiceResponse) => { 40 40 const host = data?.hostname || data?.encrypted_config?.hostname || ""; 41 - setPdsHost(host ? `https://${stripScheme(host)}` : ""); 41 + setPdsBareHost(stripScheme(host)); 42 42 }) 43 43 .catch((e) => setFetchError(e instanceof Error ? e.message : "Failed to load PDS info")); 44 44 }, []); 45 45 46 - const call = async (path: string, body: unknown) => { 47 - setLoading(true); 48 - setActionError(null); 49 - setActionSuccess(null); 50 - try { 51 - const res = await fetch(path, { 52 - method: "POST", 53 - headers: { "Content-Type": "application/json" }, 54 - body: JSON.stringify(body), 55 - }); 56 - const payload = await res.json().catch(() => ({})); 57 - if (!res.ok) { 58 - throw new Error(payload?.message || payload?.payload?.message || "Request failed"); 59 - } 60 - return payload; 61 - } finally { 62 - setLoading(false); 63 - } 64 - }; 46 + if (fetchError) { 47 + return <Paragraph className="text-sm text-rose-300">{fetchError}</Paragraph>; 48 + } 65 49 66 - const createInvite = async () => { 67 - try { 68 - const payload = await call("/api/pds/atproto/invite", { useCount: 1 }); 69 - const code = payload?.code || payload?.inviteCode || ""; 70 - setInviteCode(code); 71 - } catch (e) { 72 - setActionError(e instanceof Error ? e.message : String(e)); 73 - } 74 - }; 50 + return ( 51 + <div className="grid gap-6 md:grid-cols-2"> 52 + <CreateUserSection pdsBareHost={pdsBareHost} /> 53 + <InviteSection /> 54 + </div> 55 + ); 56 + } 75 57 76 - const createAccount = async () => { 77 - if (!handle || !password || !inviteCode) return; 58 + function CreateUserSection({ pdsBareHost }: { pdsBareHost: string }) { 59 + const [handle, setHandle] = useState(""); 60 + const [password, setPassword] = useState(""); 61 + const [loading, setLoading] = useState(false); 62 + const [error, setError] = useState<string | null>(null); 63 + const [success, setSuccess] = useState<string | null>(null); 64 + 65 + const fullHandle = useMemo( 66 + () => (handle && pdsBareHost ? `${handle}.${pdsBareHost}` : ""), 67 + [handle, pdsBareHost], 68 + ); 69 + 70 + const submit = async () => { 71 + if (!handle || !password) return; 72 + setLoading(true); 73 + setError(null); 74 + setSuccess(null); 78 75 try { 79 - await call("/api/pds/atproto/create-account", { 76 + await apiCall("/api/pds/atproto/create-account", { 80 77 handle: fullHandle, 81 78 password, 82 - inviteCode, 83 79 }); 84 - setActionSuccess(`Account created: ${fullHandle}`); 80 + setSuccess(`Account created: ${fullHandle}`); 85 81 setHandle(""); 86 82 setPassword(""); 87 - setInviteCode(""); 88 83 } catch (e) { 89 - setActionError(e instanceof Error ? e.message : String(e)); 84 + setError(e instanceof Error ? e.message : String(e)); 85 + } finally { 86 + setLoading(false); 90 87 } 91 88 }; 92 89 93 - if (fetchError) { 94 - return ( 95 - <Paragraph className="text-sm text-rose-300">{fetchError}</Paragraph> 96 - ); 97 - } 98 - 99 90 return ( 100 - <div className="space-y-8"> 101 - {/* Invite code */} 102 - <section className="space-y-3"> 103 - <Paragraph className="text-sm font-semibold text-white/80 uppercase tracking-wide"> 104 - Invitation code 105 - </Paragraph> 106 - <div className="flex flex-wrap items-center gap-3"> 107 - <Button onClick={createInvite} disabled={loading || !pdsHost} className="rounded-full"> 108 - {loading ? "Working…" : "Generate invite"} 109 - </Button> 110 - {inviteCode && ( 111 - <Paragraph className="font-mono text-sm text-white break-all">{inviteCode}</Paragraph> 112 - )} 113 - </div> 114 - </section> 115 - 116 - {/* Create user */} 117 - <section className="space-y-4"> 118 - <Paragraph className="text-sm font-semibold text-white/80 uppercase tracking-wide"> 119 - Create user 91 + <section className="space-y-4 rounded-md border border-white/10 bg-white/5 p-4"> 92 + <div> 93 + <Paragraph className="text-sm font-semibold text-white">Create user</Paragraph> 94 + <Paragraph className="text-xs text-white/50 mt-1"> 95 + Directly create an account on your PDS. 120 96 </Paragraph> 121 - <div className="grid gap-3 sm:grid-cols-2"> 122 - <div className="space-y-1"> 123 - <Label htmlFor="handle">Handle</Label> 124 - <div className="flex items-center gap-1"> 125 - <Input 126 - id="handle" 127 - value={handle} 128 - onChange={(e) => setHandle(e.target.value)} 129 - placeholder="username" 130 - className="flex-1" 131 - /> 132 - {pdsBareHost && ( 133 - <Paragraph className="text-xs text-white/40 whitespace-nowrap">.{pdsBareHost}</Paragraph> 134 - )} 135 - </div> 136 - </div> 137 - <div className="space-y-1"> 138 - <Label htmlFor="password">Password</Label> 97 + </div> 98 + <div className="space-y-3"> 99 + <div className="space-y-1"> 100 + <Label htmlFor="handle">Handle</Label> 101 + <div className="flex items-center gap-1"> 139 102 <Input 140 - id="password" 141 - type="password" 142 - value={password} 143 - onChange={(e) => setPassword(e.target.value)} 144 - placeholder="Password" 103 + id="handle" 104 + value={handle} 105 + onChange={(e) => setHandle(e.target.value)} 106 + placeholder="username" 107 + className="flex-1" 145 108 /> 146 - </div> 147 - <div className="space-y-1 sm:col-span-2"> 148 - <Label htmlFor="invite">Invite code</Label> 149 - <Input 150 - id="invite" 151 - value={inviteCode} 152 - onChange={(e) => setInviteCode(e.target.value)} 153 - placeholder="Paste invite code or generate one above" 154 - className="font-mono" 155 - /> 109 + {pdsBareHost && ( 110 + <Paragraph className="text-xs text-white/40 whitespace-nowrap">.{pdsBareHost}</Paragraph> 111 + )} 156 112 </div> 157 113 </div> 158 - <Button 159 - onClick={createAccount} 160 - disabled={loading || !handle || !password || !inviteCode || !pdsHost} 161 - className="rounded-full" 162 - > 163 - {loading ? "Working…" : "Create account"} 164 - </Button> 165 - </section> 114 + <div className="space-y-1"> 115 + <Label htmlFor="new-password">Password</Label> 116 + <Input 117 + id="new-password" 118 + type="password" 119 + value={password} 120 + onChange={(e) => setPassword(e.target.value)} 121 + placeholder="Password" 122 + /> 123 + </div> 124 + </div> 125 + <Button 126 + onClick={submit} 127 + disabled={loading || !handle || !password || !pdsBareHost} 128 + className="rounded-full w-full" 129 + > 130 + {loading ? "Creating…" : "Create account"} 131 + </Button> 132 + {success && <Paragraph className="text-sm text-emerald-300">{success}</Paragraph>} 133 + {error && <Paragraph className="text-sm text-rose-300 break-all">{error}</Paragraph>} 134 + </section> 135 + ); 136 + } 166 137 167 - {actionSuccess && ( 168 - <Paragraph className="text-sm text-emerald-300">{actionSuccess}</Paragraph> 169 - )} 170 - {actionError && ( 171 - <Paragraph className="text-sm text-rose-300 break-all">{actionError}</Paragraph> 138 + function InviteSection() { 139 + const [inviteCode, setInviteCode] = useState<string | null>(null); 140 + const [loading, setLoading] = useState(false); 141 + const [error, setError] = useState<string | null>(null); 142 + const [copied, setCopied] = useState(false); 143 + 144 + const generate = async () => { 145 + setLoading(true); 146 + setError(null); 147 + setInviteCode(null); 148 + try { 149 + const payload = await apiCall("/api/pds/atproto/invite", { useCount: 1 }); 150 + setInviteCode(payload?.code || payload?.inviteCode || ""); 151 + } catch (e) { 152 + setError(e instanceof Error ? e.message : String(e)); 153 + } finally { 154 + setLoading(false); 155 + } 156 + }; 157 + 158 + const copy = () => { 159 + if (!inviteCode) return; 160 + navigator.clipboard.writeText(inviteCode); 161 + setCopied(true); 162 + setTimeout(() => setCopied(false), 2000); 163 + }; 164 + 165 + return ( 166 + <section className="space-y-4 rounded-md border border-white/10 bg-white/5 p-4"> 167 + <div> 168 + <Paragraph className="text-sm font-semibold text-white">Invite someone</Paragraph> 169 + <Paragraph className="text-xs text-white/50 mt-1"> 170 + Generate a one-time code for an external person to create their own account. 171 + </Paragraph> 172 + </div> 173 + <Button onClick={generate} disabled={loading} className="rounded-full w-full"> 174 + {loading ? "Generating…" : "Generate invite code"} 175 + </Button> 176 + {inviteCode && ( 177 + <div className="space-y-2"> 178 + <Paragraph className="font-mono text-sm text-white break-all rounded bg-black/20 p-2"> 179 + {inviteCode} 180 + </Paragraph> 181 + <Button onClick={copy} className="rounded-full w-full"> 182 + {copied ? "Copied!" : "Copy code"} 183 + </Button> 184 + </div> 172 185 )} 173 - </div> 186 + {error && <Paragraph className="text-sm text-rose-300 break-all">{error}</Paragraph>} 187 + </section> 174 188 ); 175 189 }