Coffee journaling on ATProto (alpha) alpha.arabica.social
coffee

feat: replace create page with prompt=create pds request

pdewey.com bcfeec5e 4d52dda0

verified
+358 -199
+1
.gitignore
··· 52 52 known-dids.txt 53 53 moderators.json 54 54 roles.json 55 + pds-config.json
+12
cmd/server/main.go
··· 337 337 log.Info().Msg("Email notifications disabled (SMTP_HOST not set), join requests will be saved to database only") 338 338 } 339 339 340 + // Configure signup PDS servers for prompt=create account registration 341 + signupPDSConfigPath := os.Getenv("ARABICA_SIGNUP_PDS_CONFIG") 342 + signupPDSConfig, err := handlers.LoadSignupPDSConfig(signupPDSConfigPath) 343 + if err != nil { 344 + log.Warn().Err(err).Msg("Failed to load signup PDS config, signup disabled") 345 + } else if signupPDSConfig != nil { 346 + h.SetSignupPDSConfig(signupPDSConfig) 347 + log.Info().Int("servers", len(signupPDSConfig.Servers)).Msg("Signup PDS servers configured") 348 + } else { 349 + log.Info().Msg("No signup PDS config (ARABICA_SIGNUP_PDS_CONFIG not set)") 350 + } 351 + 340 352 // Setup router with middleware 341 353 handler := routing.SetupRouter(routing.Config{ 342 354 Handlers: h,
+16
config/signup-pds.json.example
··· 1 + { 2 + "servers": [ 3 + { 4 + "url": "https://arabica.systems", 5 + "name": "Arabica Systems", 6 + "description": "The official Arabica PDS. Invite only.", 7 + "invite_only": true 8 + }, 9 + { 10 + "url": "https://bsky.social", 11 + "name": "Bluesky Social", 12 + "description": "The main Bluesky PDS. Open registration.", 13 + "invite_only": false 14 + } 15 + ] 16 + }
+170
internal/atproto/oauth.go
··· 1 1 package atproto 2 2 3 3 import ( 4 + "bytes" 4 5 "context" 6 + "crypto/rand" 7 + "encoding/base64" 8 + "encoding/json" 5 9 "fmt" 6 10 "net/http" 7 11 "net/url" 8 12 "strings" 9 13 14 + "github.com/bluesky-social/indigo/atproto/atcrypto" 10 15 "github.com/bluesky-social/indigo/atproto/auth/oauth" 11 16 "github.com/bluesky-social/indigo/atproto/syntax" 17 + 18 + "github.com/google/go-querystring/query" 12 19 ) 13 20 14 21 var scopes = []string{ ··· 201 208 return syntax.ParseDID(didStr) 202 209 } 203 210 211 + // InitiateSignup starts an OAuth flow with prompt=create for account registration. 212 + // The pdsURL should be the PDS host URL (e.g., "https://arabica.systems"). 213 + // Returns the authorization URL to redirect the user to. 214 + func (m *OAuthManager) InitiateSignup(ctx context.Context, pdsURL string) (string, error) { 215 + app := m.app 216 + 217 + // Resolve auth server from PDS URL 218 + authserverURL, err := app.Resolver.ResolveAuthServerURL(ctx, pdsURL) 219 + if err != nil { 220 + return "", fmt.Errorf("resolving auth server for %s: %w", pdsURL, err) 221 + } 222 + 223 + authserverMeta, err := app.Resolver.ResolveAuthServerMetadata(ctx, authserverURL) 224 + if err != nil { 225 + return "", fmt.Errorf("fetching auth server metadata: %w", err) 226 + } 227 + 228 + // Send PAR with prompt=create 229 + info, err := m.sendAuthRequestWithPrompt(ctx, authserverMeta, app.Config.Scopes, "create") 230 + if err != nil { 231 + return "", fmt.Errorf("auth request failed: %w", err) 232 + } 233 + 234 + // Persist auth request info 235 + if err := app.Store.SaveAuthRequestInfo(ctx, *info); err != nil { 236 + return "", fmt.Errorf("saving auth request: %w", err) 237 + } 238 + 239 + params := url.Values{} 240 + params.Set("client_id", app.Config.ClientID) 241 + params.Set("request_uri", info.RequestURI) 242 + 243 + redirectURL := fmt.Sprintf("%s?%s", authserverMeta.AuthorizationEndpoint, params.Encode()) 244 + return redirectURL, nil 245 + } 246 + 247 + // sendAuthRequestWithPrompt sends a PAR request with an optional prompt parameter. 248 + // This mirrors the SDK's SendAuthRequest but adds prompt support. 249 + func (m *OAuthManager) sendAuthRequestWithPrompt(ctx context.Context, authMeta *oauth.AuthServerMetadata, scopes []string, prompt string) (*oauth.AuthRequestData, error) { 250 + app := m.app 251 + parURL := authMeta.PushedAuthorizationRequestEndpoint 252 + 253 + state := secureRandomBase64(16) 254 + pkceVerifier := secureRandomBase64(48) 255 + codeChallenge := oauth.S256CodeChallenge(pkceVerifier) 256 + 257 + body := oauth.PushedAuthRequest{ 258 + ClientID: app.Config.ClientID, 259 + State: state, 260 + RedirectURI: app.Config.CallbackURL, 261 + Scope: strings.Join(scopes, " "), 262 + ResponseType: "code", 263 + CodeChallenge: codeChallenge, 264 + CodeChallengeMethod: "S256", 265 + } 266 + 267 + if prompt != "" { 268 + body.Prompt = &prompt 269 + } 270 + 271 + if app.Config.IsConfidential() { 272 + assertionJWT, err := app.Config.NewClientAssertion(authMeta.Issuer) 273 + if err != nil { 274 + return nil, err 275 + } 276 + body.ClientAssertionType = oauth.ClientAssertionJWTBearer 277 + body.ClientAssertion = assertionJWT 278 + } 279 + 280 + vals, err := query.Values(body) 281 + if err != nil { 282 + return nil, err 283 + } 284 + bodyBytes := []byte(vals.Encode()) 285 + 286 + dpopServerNonce := "" 287 + dpopPrivKey, err := atcrypto.GeneratePrivateKeyP256() 288 + if err != nil { 289 + return nil, err 290 + } 291 + 292 + var resp *http.Response 293 + for range 2 { 294 + dpopJWT, err := oauth.NewAuthDPoP("POST", parURL, dpopServerNonce, dpopPrivKey) 295 + if err != nil { 296 + return nil, err 297 + } 298 + 299 + req, err := http.NewRequestWithContext(ctx, "POST", parURL, bytes.NewBuffer(bodyBytes)) 300 + if err != nil { 301 + return nil, err 302 + } 303 + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") 304 + req.Header.Set("DPoP", dpopJWT) 305 + 306 + client := app.Client 307 + if client == nil { 308 + client = http.DefaultClient 309 + } 310 + resp, err = client.Do(req) 311 + if err != nil { 312 + return nil, err 313 + } 314 + 315 + dpopServerNonce = resp.Header.Get("DPoP-Nonce") 316 + 317 + if resp.StatusCode == http.StatusBadRequest && dpopServerNonce != "" { 318 + // Check if it's a DPoP nonce error; if so retry 319 + var errBody struct { 320 + Error string `json:"error"` 321 + } 322 + bodyData := mustReadBody(resp) 323 + _ = json.Unmarshal(bodyData, &errBody) 324 + if errBody.Error == "use_dpop_nonce" { 325 + continue 326 + } 327 + return nil, fmt.Errorf("PAR request failed (HTTP %d): %s", resp.StatusCode, string(bodyData)) 328 + } 329 + 330 + break 331 + } 332 + 333 + defer resp.Body.Close() 334 + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { 335 + bodyData := mustReadBody(resp) 336 + return nil, fmt.Errorf("PAR request failed (HTTP %d): %s", resp.StatusCode, string(bodyData)) 337 + } 338 + 339 + var parResp oauth.PushedAuthResponse 340 + if err := json.NewDecoder(resp.Body).Decode(&parResp); err != nil { 341 + return nil, fmt.Errorf("PAR response decode failed: %w", err) 342 + } 343 + 344 + info := &oauth.AuthRequestData{ 345 + State: state, 346 + AuthServerURL: authMeta.Issuer, 347 + Scopes: scopes, 348 + PKCEVerifier: pkceVerifier, 349 + RequestURI: parResp.RequestURI, 350 + AuthServerTokenEndpoint: authMeta.TokenEndpoint, 351 + AuthServerRevocationEndpoint: authMeta.RevocationEndpoint, 352 + DPoPAuthServerNonce: dpopServerNonce, 353 + DPoPPrivateKeyMultibase: dpopPrivKey.Multibase(), 354 + } 355 + 356 + return info, nil 357 + } 358 + 359 + // secureRandomBase64 generates a cryptographically random base64url-encoded string. 360 + func secureRandomBase64(sizeBytes uint) string { 361 + b := make([]byte, sizeBytes) 362 + _, _ = rand.Read(b) 363 + return base64.RawURLEncoding.EncodeToString(b) 364 + } 365 + 366 + // mustReadBody reads and closes the response body, returning the bytes. 367 + func mustReadBody(resp *http.Response) []byte { 368 + defer resp.Body.Close() 369 + var buf bytes.Buffer 370 + buf.ReadFrom(resp.Body) 371 + return buf.Bytes() 372 + } 373 +
+45
internal/handlers/handlers.go
··· 5 5 "encoding/json" 6 6 "fmt" 7 7 "net/http" 8 + "os" 8 9 "strings" 9 10 10 11 "arabica/internal/atproto" ··· 53 54 joinStore *boltstore.JoinStore 54 55 pdsAdminURL string 55 56 pdsAdminToken string 57 + 58 + // Signup PDS config for prompt=create OAuth flow 59 + signupPDSConfig *SignupPDSConfig 56 60 } 57 61 58 62 // NewHandler creates a new Handler with all required dependencies. ··· 92 96 h.joinStore = store 93 97 h.pdsAdminURL = pdsURL 94 98 h.pdsAdminToken = pdsAdminToken 99 + } 100 + 101 + // SignupPDSServer represents a PDS server available for account registration. 102 + type SignupPDSServer struct { 103 + URL string `json:"url"` 104 + Name string `json:"name"` 105 + Description string `json:"description"` 106 + InviteOnly bool `json:"invite_only"` 107 + } 108 + 109 + // SignupPDSConfig holds the configuration for PDS servers available for signup. 110 + type SignupPDSConfig struct { 111 + Servers []SignupPDSServer `json:"servers"` 112 + } 113 + 114 + // LoadSignupPDSConfig reads signup PDS configuration from a JSON file. 115 + // Returns nil config (not an error) if path is empty or file doesn't exist. 116 + func LoadSignupPDSConfig(path string) (*SignupPDSConfig, error) { 117 + if path == "" { 118 + return nil, nil 119 + } 120 + 121 + data, err := os.ReadFile(path) 122 + if err != nil { 123 + if os.IsNotExist(err) { 124 + return nil, nil 125 + } 126 + return nil, fmt.Errorf("failed to read signup PDS config: %w", err) 127 + } 128 + 129 + var config SignupPDSConfig 130 + if err := json.Unmarshal(data, &config); err != nil { 131 + return nil, fmt.Errorf("failed to parse signup PDS config: %w", err) 132 + } 133 + 134 + return &config, nil 135 + } 136 + 137 + // SetSignupPDSConfig configures the PDS servers available for account registration 138 + func (h *Handler) SetSignupPDSConfig(config *SignupPDSConfig) { 139 + h.signupPDSConfig = config 95 140 } 96 141 97 142 // validateRKey validates and returns an rkey from a path parameter.
+51 -88
internal/handlers/join.go
··· 4 4 "errors" 5 5 "fmt" 6 6 "net/http" 7 + "net/url" 7 8 "strings" 8 9 "time" 9 10 ··· 154 155 subject := "Your Arabica Invite Code" 155 156 // TODO: this should probably use the env var rather than hard coded (for name/url) 156 157 // TODO: also this could be a template file 157 - body := fmt.Sprintf("Welcome to Arabica!\n\nHere is your invite code to create an account on the arabica.systems PDS:\n\n %s\n\nVisit https://arabica.social/join to sign up with this code.\n\nHappy brewing!\n", out.Code) 158 + createURL := "https://arabica.social/join" 159 + 160 + body := fmt.Sprintf("Welcome to Arabica!\n\nHere is your invite code to create an account on the arabica.systems PDS:\n\n %s\n\nVisit %s to sign up with this code.\n\nHappy brewing!\n", out.Code, createURL) 158 161 if err := h.emailSender.Send(reqEmail, subject, body); err != nil { 159 162 log.Error().Err(err).Str("email", reqEmail).Str("code", out.Code).Msg("Failed to send invite email") 160 163 http.Error(w, "Invite created but failed to send email. Code: "+out.Code, http.StatusInternalServerError) ··· 261 264 w.WriteHeader(http.StatusOK) 262 265 } 263 266 264 - // HandleCreateAccount renders the account creation form (GET /join/create). 267 + // HandleCreateAccount renders the account creation page (GET /join/create). 268 + // Shows available PDS servers for account registration via OAuth prompt=create. 265 269 func (h *Handler) HandleCreateAccount(w http.ResponseWriter, r *http.Request) { 266 270 layoutData, _, _ := h.layoutDataFromRequest(r, "Create Account") 267 271 272 + var pdsOptions []pages.PDSOption 273 + if h.signupPDSConfig != nil { 274 + for _, s := range h.signupPDSConfig.Servers { 275 + u, err := url.Parse(s.URL) 276 + if err != nil { 277 + log.Warn().Str("url", s.URL).Err(err).Msg("Invalid signup PDS URL, skipping") 278 + continue 279 + } 280 + pdsOptions = append(pdsOptions, pages.PDSOption{ 281 + URL: s.URL, 282 + Host: u.Host, 283 + Name: s.Name, 284 + Description: s.Description, 285 + InviteOnly: s.InviteOnly, 286 + }) 287 + } 288 + } 289 + 268 290 props := pages.CreateAccountProps{ 269 - InviteCode: r.URL.Query().Get("code"), 270 - HandleDomain: "arabica.systems", 291 + PDSOptions: pdsOptions, 292 + Error: r.URL.Query().Get("error"), 271 293 } 272 294 273 295 if err := pages.CreateAccount(layoutData, props).Render(r.Context(), w); err != nil { ··· 276 298 } 277 299 } 278 300 279 - // HandleCreateAccountSubmit processes the account creation form (POST /join/create). 301 + // HandleCreateAccountSubmit initiates the OAuth prompt=create flow (POST /join/create). 280 302 func (h *Handler) HandleCreateAccountSubmit(w http.ResponseWriter, r *http.Request) { 303 + if h.oauth == nil { 304 + http.Error(w, "OAuth not configured", http.StatusInternalServerError) 305 + return 306 + } 307 + 281 308 if err := r.ParseForm(); err != nil { 282 309 http.Error(w, "Invalid request", http.StatusBadRequest) 283 310 return 284 311 } 285 312 286 - inviteCode := strings.TrimSpace(r.FormValue("invite_code")) 287 - handle := strings.TrimSpace(r.FormValue("handle")) 288 - emailAddr := strings.TrimSpace(r.FormValue("email")) 289 - password := r.FormValue("password") 290 - passwordConfirm := r.FormValue("password_confirm") 291 - honeypot := r.FormValue("website") 292 - 293 - // Honeypot check — bots fill hidden fields; show fake success 294 - if honeypot != "" { 295 - layoutData, _, _ := h.layoutDataFromRequest(r, "Account Created") 296 - _ = pages.CreateAccountSuccess(layoutData, pages.CreateAccountSuccessProps{Handle: "user.arabica.systems"}).Render(r.Context(), w) 313 + pdsURL := r.FormValue("pds_url") 314 + if pdsURL == "" { 315 + http.Redirect(w, r, "/join/create?error=Please+select+a+server", http.StatusSeeOther) 297 316 return 298 317 } 299 318 300 - handleDomain := "arabica.systems" 301 - 302 - // Render form with error helper 303 - renderError := func(msg string) { 304 - layoutData, _, _ := h.layoutDataFromRequest(r, "Create Account") 305 - props := pages.CreateAccountProps{ 306 - Error: msg, 307 - InviteCode: inviteCode, 308 - Handle: handle, 309 - Email: emailAddr, 310 - HandleDomain: handleDomain, 319 + // Validate the PDS URL is in our allowed list 320 + allowed := false 321 + if h.signupPDSConfig != nil { 322 + for _, s := range h.signupPDSConfig.Servers { 323 + if s.URL == pdsURL { 324 + allowed = true 325 + break 326 + } 311 327 } 312 - if err := pages.CreateAccount(layoutData, props).Render(r.Context(), w); err != nil { 313 - http.Error(w, "Failed to render page", http.StatusInternalServerError) 314 - } 315 - } 316 - 317 - // Validate required fields 318 - if inviteCode == "" || handle == "" || emailAddr == "" || password == "" { 319 - renderError("All fields are required.") 320 - return 321 328 } 322 - if password != passwordConfirm { 323 - renderError("Passwords do not match.") 329 + if !allowed { 330 + log.Warn().Str("pds_url", pdsURL).Msg("Signup attempt with unlisted PDS URL") 331 + http.Redirect(w, r, "/join/create?error=Invalid+server+selection", http.StatusSeeOther) 324 332 return 325 333 } 326 334 327 - // Build full handle 328 - fullHandle := handle + "." + handleDomain 329 - 330 - if h.pdsAdminURL == "" { 331 - renderError("Account creation is not available at this time.") 332 - log.Error().Msg("PDS admin URL not configured for account creation") 333 - return 334 - } 335 - 336 - // Call PDS createAccount (public endpoint, no admin token needed) 337 - log.Info().Str("handle", fullHandle).Str("email", emailAddr).Str("pds_url", h.pdsAdminURL).Msg("Creating account via PDS") 338 - client := &xrpc.Client{Host: h.pdsAdminURL} 339 - out, err := comatproto.ServerCreateAccount(r.Context(), client, &comatproto.ServerCreateAccount_Input{ 340 - Handle: fullHandle, 341 - Email: &emailAddr, 342 - Password: &password, 343 - InviteCode: &inviteCode, 344 - }) 335 + // Initiate OAuth flow with prompt=create 336 + authURL, err := h.oauth.InitiateSignup(r.Context(), pdsURL) 345 337 if err != nil { 346 - errMsg := "Account creation failed. Please try again." 347 - var xrpcErr *xrpc.Error 348 - if errors.As(err, &xrpcErr) { 349 - var inner *xrpc.XRPCError 350 - if errors.As(xrpcErr.Wrapped, &inner) { 351 - switch inner.ErrStr { 352 - case "InvalidInviteCode": 353 - errMsg = "Invalid or expired invite code." 354 - case "HandleNotAvailable": 355 - errMsg = "This handle is already taken." 356 - case "InvalidHandle": 357 - errMsg = "Invalid handle format. Use only letters, numbers, and hyphens." 358 - default: 359 - if inner.Message != "" { 360 - errMsg = inner.Message 361 - } 362 - } 363 - } 364 - } 365 - logEvent := log.Error().Err(err).Str("handle", fullHandle).Str("email", emailAddr).Str("pds_url", h.pdsAdminURL) 366 - if xrpcErr2, ok := err.(*xrpc.Error); ok { 367 - logEvent = logEvent.Int("status_code", xrpcErr2.StatusCode) 368 - } 369 - logEvent.Msg("Failed to create account") 370 - renderError(errMsg) 338 + log.Error().Err(err).Str("pds_url", pdsURL).Msg("Failed to initiate signup flow") 339 + http.Redirect(w, r, "/join/create?error=Failed+to+connect+to+server.+Please+try+again.", http.StatusSeeOther) 371 340 return 372 341 } 373 342 374 - log.Info().Str("handle", out.Handle).Str("did", out.Did).Msg("Account created") 375 - 376 - layoutData, _, _ := h.layoutDataFromRequest(r, "Account Created") 377 - if err := pages.CreateAccountSuccess(layoutData, pages.CreateAccountSuccessProps{Handle: out.Handle}).Render(r.Context(), w); err != nil { 378 - http.Error(w, "Failed to render page", http.StatusInternalServerError) 379 - log.Error().Err(err).Msg("Failed to render create account success page") 380 - } 343 + http.Redirect(w, r, authURL, http.StatusFound) 381 344 }
+63 -111
internal/web/pages/create_account.templ
··· 2 2 3 3 import "arabica/internal/web/components" 4 4 5 - // CreateAccountProps holds form state for the account creation page. 5 + // PDSOption represents a PDS server available for account registration. 6 + type PDSOption struct { 7 + URL string // Full URL (e.g., "https://arabica.systems") 8 + Host string // Display hostname (e.g., "arabica.systems") 9 + Name string // Human-friendly name (e.g., "Arabica Systems") 10 + Description string // Description shown to user 11 + InviteOnly bool // Whether the server requires an invite code 12 + } 13 + 14 + // CreateAccountProps holds state for the account creation page. 6 15 type CreateAccountProps struct { 7 - Error string // Validation or XRPC error message 8 - InviteCode string // Pre-filled or re-populated invite code 9 - Handle string // Re-populated handle on error 10 - Email string // Re-populated email on error 11 - HandleDomain string // e.g. "arabica.systems" 16 + PDSOptions []PDSOption // Available PDS servers for registration 17 + Error string // Error message from failed signup attempt 12 18 } 13 19 14 20 // CreateAccount renders the account creation page with layout. ··· 16 22 @components.Layout(layout, CreateAccountContent(props)) 17 23 } 18 24 19 - // CreateAccountContent renders the account creation form. 25 + // CreateAccountContent renders the PDS server selection for account creation. 20 26 templ CreateAccountContent(props CreateAccountProps) { 21 27 <div class="page-container-md"> 22 28 <div class="flex items-center gap-3 mb-8"> ··· 24 30 <h1 class="text-4xl font-bold text-brown-900">Create Account</h1> 25 31 </div> 26 32 <p class="text-brown-800 leading-relaxed mb-6"> 27 - Create your account on <strong>{ props.HandleDomain }</strong> using your invite code. 33 + Create an account on a Personal Data Server (PDS) to start tracking your brews. 34 + Your data is stored on the server you choose and is portable — you can move it later. 28 35 </p> 29 36 if props.Error != "" { 30 37 <div class="mb-6 rounded-lg border border-red-300 bg-red-50 p-4 text-red-800 text-sm"> 31 38 { props.Error } 32 39 </div> 33 40 } 34 - <div class="card card-inner"> 35 - <form method="POST" action="/join/create" class="space-y-5"> 36 - @components.FormField(components.FormFieldProps{ 37 - Label: "Invite Code", 38 - Required: true, 39 - }, components.TextInput(components.TextInputProps{ 40 - Name: "invite_code", 41 - Value: props.InviteCode, 42 - Placeholder: "arabica-systems-xxxxx-xxxxx", 43 - Required: true, 44 - Class: "w-full", 45 - })) 46 - @components.FormField(components.FormFieldProps{ 47 - Label: "Handle", 48 - Required: true, 49 - HelperText: "Your @handle." + props.HandleDomain + " username", 50 - }, components.TextInput(components.TextInputProps{ 51 - Name: "handle", 52 - Value: props.Handle, 53 - Placeholder: "yourname", 54 - Required: true, 55 - Class: "w-full", 56 - })) 57 - @components.FormField(components.FormFieldProps{ 58 - Label: "Email", 59 - Required: true, 60 - }, components.TextInput(components.TextInputProps{ 61 - Name: "email", 62 - Type: "email", 63 - Value: props.Email, 64 - Placeholder: "you@example.com", 65 - Required: true, 66 - Class: "w-full", 67 - })) 68 - @components.FormField(components.FormFieldProps{ 69 - Label: "Password", 70 - Required: true, 71 - }, components.TextInput(components.TextInputProps{ 72 - Name: "password", 73 - Type: "password", 74 - Placeholder: "Choose a strong password", 75 - Required: true, 76 - Class: "w-full", 77 - })) 78 - @components.FormField(components.FormFieldProps{ 79 - Label: "Confirm Password", 80 - Required: true, 81 - }, components.TextInput(components.TextInputProps{ 82 - Name: "password_confirm", 83 - Type: "password", 84 - Placeholder: "Confirm your password", 85 - Required: true, 86 - Class: "w-full", 87 - })) 88 - <!-- Honeypot field — hidden from real users --> 89 - <div style="display:none" aria-hidden="true"> 90 - <label for="website">Website</label> 91 - <input type="text" name="website" id="website" tabindex="-1" autocomplete="off"/> 92 - </div> 93 - <div class="pt-2 text-center"> 94 - @components.PrimaryButton(components.ButtonProps{ 95 - Text: "Create Account", 96 - Type: "submit", 97 - Class: "w-full", 98 - }) 99 - </div> 100 - </form> 101 - </div> 41 + if len(props.PDSOptions) > 0 { 42 + <div class="space-y-4"> 43 + for _, pds := range props.PDSOptions { 44 + <div class="card card-inner"> 45 + <form method="POST" action="/join/create"> 46 + <input type="hidden" name="pds_url" value={ pds.URL }/> 47 + <div class="flex items-center justify-between gap-4"> 48 + <div class="min-w-0"> 49 + <div class="flex items-center gap-2 mb-1"> 50 + <p class="text-lg font-semibold text-brown-900"> 51 + if pds.Name != "" { 52 + { pds.Name } 53 + } else { 54 + { pds.Host } 55 + } 56 + </p> 57 + if pds.InviteOnly { 58 + <span class="inline-flex items-center rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800"> 59 + Invite Only 60 + </span> 61 + } else { 62 + <span class="inline-flex items-center rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800"> 63 + Open 64 + </span> 65 + } 66 + </div> 67 + if pds.Description != "" { 68 + <p class="text-sm text-brown-700 mb-1">{ pds.Description }</p> 69 + } 70 + <p class="text-xs text-brown-500">{ pds.Host }</p> 71 + </div> 72 + @components.PrimaryButton(components.ButtonProps{ 73 + Text: "Create Account", 74 + Type: "submit", 75 + }) 76 + </div> 77 + </form> 78 + </div> 79 + } 80 + </div> 81 + } else { 82 + @components.EmptyState(components.EmptyStateProps{ 83 + Message: "No servers available for registration", 84 + SubMessage: "Account registration is not currently available. You can request an invite instead.", 85 + ActionURL: "/join", 86 + ActionText: "Request an Invite", 87 + }) 88 + } 102 89 <p class="text-sm text-brown-600 mt-6 text-center"> 103 90 Already have an account? 104 91 <a href="/login" class="link-bold">Log in here</a>. 105 92 </p> 106 93 </div> 107 94 } 108 - 109 - // CreateAccountSuccessProps holds data for the success page. 110 - type CreateAccountSuccessProps struct { 111 - Handle string // The created handle (e.g. "yourname.arabica.systems") 112 - } 113 - 114 - // CreateAccountSuccess renders the success page after account creation. 115 - templ CreateAccountSuccess(layout *components.LayoutData, props CreateAccountSuccessProps) { 116 - @components.Layout(layout, CreateAccountSuccessContent(props)) 117 - } 118 - 119 - // CreateAccountSuccessContent renders the success message. 120 - templ CreateAccountSuccessContent(props CreateAccountSuccessProps) { 121 - <div class="page-container-md"> 122 - <div class="flex items-center gap-3 mb-8"> 123 - @components.BackButton() 124 - <h1 class="text-4xl font-bold text-brown-900">Account Created</h1> 125 - </div> 126 - <div class="card card-inner text-center py-12"> 127 - <svg class="w-16 h-16 mx-auto mb-6 text-green-600" fill="none" stroke="currentColor" stroke-width="1.5" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> 128 - <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"></path> 129 - </svg> 130 - <h2 class="text-2xl font-semibold text-brown-900 mb-4">Welcome to Arabica!</h2> 131 - <p class="text-brown-800 leading-relaxed mb-2"> 132 - Your account <strong>{ props.Handle }</strong> has been created. 133 - </p> 134 - <p class="text-brown-700 text-sm mb-8"> 135 - You can now log in and start tracking your brews. 136 - </p> 137 - <a href="/login" class="btn-primary px-8 py-3 font-semibold shadow-lg hover:shadow-xl"> 138 - Log In 139 - </a> 140 - </div> 141 - </div> 142 - }