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