···11+package handlers
22+33+import (
44+ "log/slog"
55+ "net/http"
66+ "strings"
77+88+ "atcr.io/pkg/auth/oauth"
99+)
1010+1111+// SignupProvider is a curated Atmosphere PDS that accepts prompt=create.
1212+//
1313+// v1 keeps the list hardcoded. When the community has a curated directory of
1414+// providers we can link or syndicate, this moves to a config file.
1515+type SignupProvider struct {
1616+ // Domain is the brand hostname shown to the user on the picker and
1717+ // interstitial — typically the site they already know (tangled.org).
1818+ Domain string
1919+2020+ // AuthServerDomain is the actual PDS origin the OAuth redirect points
2121+ // at. When blank it defaults to Domain. Set this only when the brand
2222+ // the user recognizes is not the same host as the PDS (e.g. the
2323+ // tangled.org site runs against a tngl.sh PDS).
2424+ AuthServerDomain string
2525+2626+ // AvatarPath is the provider's mark. For mono vector marks, AvatarMono
2727+ // should be true and this should point to a monochrome SVG used as a
2828+ // CSS mask (the fill value in the file is irrelevant — theme color is
2929+ // applied via `background-color` on the element). For raster avatars
3030+ // (photographs, brand-colored icons that should render unchanged),
3131+ // AvatarMono is false and this is emitted as a plain `<img>`.
3232+ AvatarPath string
3333+3434+ // AvatarMono toggles the mask-based theme-aware render pipeline. True
3535+ // for single-color SVG marks that should invert with theme; false for
3636+ // raster or multi-color art that should render as-is.
3737+ AvatarMono bool
3838+3939+ // RegionFlag is the flag emoji shown before the region code.
4040+ RegionFlag string
4141+4242+ // Region is a short code shown in the region chip ("US", "EU", ...).
4343+ Region string
4444+4545+ // RegionFullName is the long form shown in the chip's title attribute
4646+ // for hover/assistive tech ("United States", "European Union").
4747+ RegionFullName string
4848+4949+ // TermsURL and PrivacyURL link out to the provider's policies (new tab).
5050+ // Blank links are omitted rather than rendered as dead links.
5151+ TermsURL string
5252+ PrivacyURL string
5353+}
5454+5555+// AuthServerHost returns the origin string (https://domain) passed to the
5656+// OAuth resolver. Prefers AuthServerDomain when set (so the brand label on
5757+// the row can differ from the PDS — e.g. tangled.org displayed, tngl.sh
5858+// used for auth). Hardcoded scheme is intentional — PAR against http PDSes
5959+// violates the AT Protocol OAuth spec.
6060+func (p SignupProvider) AuthServerHost() string {
6161+ host := p.AuthServerDomain
6262+ if host == "" {
6363+ host = p.Domain
6464+ }
6565+ return "https://" + host
6666+}
6767+6868+// signupProviders is the v1 curated list. Order is the order users see.
6969+// Avatars live at pkg/appview/public/static/providers/*.svg.
7070+var signupProviders = []SignupProvider{
7171+ {
7272+ Domain: "selfhosted.social",
7373+ AvatarPath: "/static/providers/selfhosted-social.png",
7474+ AvatarMono: false, // raster avatar, renders as-is
7575+ RegionFlag: "\U0001F1FA\U0001F1F8", // 🇺🇸
7676+ Region: "US",
7777+ RegionFullName: "United States",
7878+ TermsURL: "https://selfhosted.social/legal",
7979+ PrivacyURL: "https://selfhosted.social/legal",
8080+ },
8181+ {
8282+ Domain: "eurosky.social",
8383+ AvatarPath: "/static/providers/eurosky-social.svg",
8484+ AvatarMono: true,
8585+ RegionFlag: "\U0001F1EA\U0001F1FA", // 🇪🇺
8686+ Region: "EU",
8787+ RegionFullName: "European Union",
8888+ TermsURL: "https://eurosky.tech/accounts/terms/",
8989+ PrivacyURL: "https://eurosky.tech/accounts/privacy/",
9090+ },
9191+}
9292+9393+// findProvider returns the entry matching the given domain, or nil.
9494+func findProvider(domain string) *SignupProvider {
9595+ domain = strings.ToLower(strings.TrimSpace(domain))
9696+ for i := range signupProviders {
9797+ if signupProviders[i].Domain == domain {
9898+ return &signupProviders[i]
9999+ }
100100+ }
101101+ return nil
102102+}
103103+104104+// SignupHandler serves GET /signup — the provider picker.
105105+type SignupHandler struct {
106106+ BaseUIHandler
107107+}
108108+109109+func (h *SignupHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
110110+ meta := NewPageMeta(
111111+ "Create your account - "+h.ClientShortName,
112112+ "Pick an Atmosphere provider to create an account that works on "+h.ClientShortName+" and every other app on the AT Protocol.",
113113+ ).WithCanonical("https://" + h.SiteURL + "/signup").
114114+ WithSiteName(h.ClientShortName)
115115+116116+ data := struct {
117117+ PageData
118118+ Meta *PageMeta
119119+ Providers []SignupProvider
120120+ }{
121121+ PageData: NewPageData(r, &h.BaseUIHandler),
122122+ Meta: meta,
123123+ Providers: signupProviders,
124124+ }
125125+126126+ if err := h.Templates.ExecuteTemplate(w, "signup", data); err != nil {
127127+ http.Error(w, err.Error(), http.StatusInternalServerError)
128128+ return
129129+ }
130130+}
131131+132132+// SignupContinueHandler serves /signup/continue — the branded handoff.
133133+//
134134+// GET renders the interstitial. POST kicks off OAuth with prompt=create and
135135+// redirects to the provider's authorize URL.
136136+type SignupContinueHandler struct {
137137+ BaseUIHandler
138138+}
139139+140140+func (h *SignupContinueHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
141141+ to := r.URL.Query().Get("to")
142142+ if r.Method == http.MethodPost {
143143+ to = r.FormValue("to")
144144+ }
145145+146146+ provider := findProvider(to)
147147+ if provider == nil {
148148+ http.Redirect(w, r, "/signup", http.StatusSeeOther)
149149+ return
150150+ }
151151+152152+ if r.Method == http.MethodPost {
153153+ redirectURL, err := oauth.StartSignupFlow(r.Context(), h.OAuthClientApp, provider.AuthServerHost())
154154+ if err != nil {
155155+ slog.Error("signup flow start failed",
156156+ "component", "signup",
157157+ "provider", provider.Domain,
158158+ "error", err,
159159+ )
160160+ http.Redirect(w, r, "/signup?error=provider_unreachable&domain="+provider.Domain, http.StatusSeeOther)
161161+ return
162162+ }
163163+ http.Redirect(w, r, redirectURL, http.StatusFound)
164164+ return
165165+ }
166166+167167+ meta := NewPageMeta(
168168+ "Create your account on "+provider.Domain+" - "+h.ClientShortName,
169169+ "You're about to be sent to "+provider.Domain+" to create your account.",
170170+ ).WithRobots("noindex").
171171+ WithSiteName(h.ClientShortName)
172172+173173+ data := struct {
174174+ PageData
175175+ Meta *PageMeta
176176+ Provider SignupProvider
177177+ }{
178178+ PageData: NewPageData(r, &h.BaseUIHandler),
179179+ Meta: meta,
180180+ Provider: *provider,
181181+ }
182182+183183+ if err := h.Templates.ExecuteTemplate(w, "signup-continue", data); err != nil {
184184+ http.Error(w, err.Error(), http.StatusInternalServerError)
185185+ return
186186+ }
187187+}
+123
pkg/appview/handlers/signup_test.go
···11+package handlers_test
22+33+import (
44+ "net/http"
55+ "net/http/httptest"
66+ "strings"
77+ "testing"
88+99+ "atcr.io/pkg/appview"
1010+ "atcr.io/pkg/appview/handlers"
1111+)
1212+1313+func TestSignupHandler_RendersProviderPicker(t *testing.T) {
1414+ templates, err := appview.Templates(nil)
1515+ if err != nil {
1616+ t.Fatalf("load templates: %v", err)
1717+ }
1818+1919+ h := &handlers.SignupHandler{
2020+ BaseUIHandler: handlers.BaseUIHandler{
2121+ Templates: templates,
2222+ RegistryURL: "seamark.dev",
2323+ SiteURL: "seamark.dev",
2424+ ClientShortName: "Seamark",
2525+ },
2626+ }
2727+2828+ req := httptest.NewRequest("GET", "/signup", nil)
2929+ rr := httptest.NewRecorder()
3030+ h.ServeHTTP(rr, req)
3131+3232+ if rr.Code != http.StatusOK {
3333+ t.Fatalf("status = %d, want 200", rr.Code)
3434+ }
3535+3636+ body := rr.Body.String()
3737+3838+ // Each provider's domain should appear as a row title.
3939+ for _, domain := range []string{"selfhosted.social", "eurosky.social"} {
4040+ if !strings.Contains(body, domain) {
4141+ t.Errorf("missing provider domain %q in body", domain)
4242+ }
4343+ }
4444+4545+ // The {appviewName} substitution should carry through from ClientShortName.
4646+ if !strings.Contains(body, "Seamark") {
4747+ t.Error("expected rendered body to include ClientShortName 'Seamark'")
4848+ }
4949+5050+ // Each row should link to the branded handoff, not directly to OAuth.
5151+ if !strings.Contains(body, "/signup/continue?to=selfhosted.social") {
5252+ t.Error("expected CTA link to /signup/continue?to={domain}")
5353+ }
5454+5555+ // Fallback route to existing Bluesky users must remain intact.
5656+ if !strings.Contains(body, `href="/auth/oauth/login"`) {
5757+ t.Error("expected footer link to /auth/oauth/login")
5858+ }
5959+}
6060+6161+func TestSignupContinueHandler_GETRendersInterstitial(t *testing.T) {
6262+ templates, err := appview.Templates(nil)
6363+ if err != nil {
6464+ t.Fatalf("load templates: %v", err)
6565+ }
6666+6767+ h := &handlers.SignupContinueHandler{
6868+ BaseUIHandler: handlers.BaseUIHandler{
6969+ Templates: templates,
7070+ RegistryURL: "seamark.dev",
7171+ SiteURL: "seamark.dev",
7272+ ClientShortName: "Seamark",
7373+ },
7474+ }
7575+7676+ req := httptest.NewRequest("GET", "/signup/continue?to=eurosky.social", nil)
7777+ rr := httptest.NewRecorder()
7878+ h.ServeHTTP(rr, req)
7979+8080+ if rr.Code != http.StatusOK {
8181+ t.Fatalf("status = %d, want 200", rr.Code)
8282+ }
8383+8484+ body := rr.Body.String()
8585+8686+ if !strings.Contains(body, "eurosky.social") {
8787+ t.Error("expected interstitial to show target domain")
8888+ }
8989+ if !strings.Contains(body, `action="/signup/continue"`) {
9090+ t.Error("expected continue form to POST back to /signup/continue")
9191+ }
9292+ if !strings.Contains(body, `href="/signup"`) {
9393+ t.Error("expected back-link to /signup")
9494+ }
9595+ // Atmosphere story anchored with concrete app names.
9696+ if !strings.Contains(body, "Bluesky") || !strings.Contains(body, "Tangled") {
9797+ t.Error("expected body copy to name Bluesky and Tangled")
9898+ }
9999+}
100100+101101+func TestSignupContinueHandler_UnknownProviderRedirectsToPicker(t *testing.T) {
102102+ templates, err := appview.Templates(nil)
103103+ if err != nil {
104104+ t.Fatalf("load templates: %v", err)
105105+ }
106106+107107+ h := &handlers.SignupContinueHandler{
108108+ BaseUIHandler: handlers.BaseUIHandler{
109109+ Templates: templates,
110110+ },
111111+ }
112112+113113+ req := httptest.NewRequest("GET", "/signup/continue?to=not-a-real-provider.example", nil)
114114+ rr := httptest.NewRecorder()
115115+ h.ServeHTTP(rr, req)
116116+117117+ if rr.Code != http.StatusSeeOther {
118118+ t.Fatalf("status = %d, want %d", rr.Code, http.StatusSeeOther)
119119+ }
120120+ if loc := rr.Header().Get("Location"); loc != "/signup" {
121121+ t.Errorf("redirect Location = %q, want /signup", loc)
122122+ }
123123+}
···730730 SAILOR INFO DISCLOSURE
731731 ---------------------------------------- */
732732 .sailor-info summary {
733733- @apply cursor-pointer text-sm text-base-content/70;
733733+ @apply cursor-pointer text-base font-medium text-base-content/80;
734734 @apply py-2 select-none;
735735 list-style: none;
736736 }
···749749 }
750750751751 .sailor-info-body {
752752- @apply text-sm text-base-content/70 space-y-2 pl-5 pt-1 pb-2;
752752+ @apply text-base text-base-content/75 leading-relaxed space-y-3 pl-5 pt-1 pb-2;
753753 }
754754755755 /* ----------------------------------------
···834834 .vuln-box-high { background-color: var(--color-severity-high); color: var(--color-severity-high-content); }
835835 .vuln-box-medium { background-color: var(--color-severity-medium); color: var(--color-severity-medium-content); }
836836 .vuln-box-low { background-color: var(--color-severity-low); color: var(--color-severity-low-content); }
837837+838838+ /* ----------------------------------------
839839+ SIGNUP PROVIDER LIST
840840+ A single bordered container where each row is a "mooring" the user
841841+ can pick between. No per-row card, no nested borders, no shadows.
842842+843843+ The single maritime gesture on this surface is the row divider:
844844+ a repeating short-dash pattern that reads as a depth-sounding plot
845845+ mark. It replaces a plain 1px rule without becoming decoration
846846+ (the dividers still do the work of separating rows).
847847+848848+ Typography intent: the domain is rendered in the mono face because
849849+ the domain IS the identity — same thing users will see in the URL
850850+ bar on the provider's signup page. Mono treats it as an instrument
851851+ label, not prose.
852852+ ---------------------------------------- */
853853+ .provider-row + .provider-row {
854854+ /* Background-image is a row of 4px dashes with 4px gaps —
855855+ the plotted-depth look without resorting to a border-image. */
856856+ background-image: linear-gradient(
857857+ to right,
858858+ var(--color-base-300) 0 4px,
859859+ transparent 4px 8px
860860+ );
861861+ background-repeat: repeat-x;
862862+ background-size: 8px 1px;
863863+ background-position: left top;
864864+ }
865865+866866+ .provider-row-inner {
867867+ @apply flex items-center gap-4 p-4 sm:px-5;
868868+ @apply transition-colors duration-150;
869869+ background: transparent;
870870+ }
871871+872872+ .provider-row:hover .provider-row-inner {
873873+ background: color-mix(in oklch, var(--color-base-200) 55%, transparent);
874874+ }
875875+876876+ .provider-mark {
877877+ @apply shrink-0 w-10 h-10 rounded-full overflow-hidden bg-base-200;
878878+ @apply flex items-center justify-center;
879879+ /* Subtle inner ring so the avatar has a defined edge even when the
880880+ art fills corner-to-corner. */
881881+ box-shadow: inset 0 0 0 1px oklch(from var(--color-base-content) l c h / 0.08);
882882+ }
883883+ .provider-mark img {
884884+ display: block;
885885+ width: 100%;
886886+ height: 100%;
887887+ object-fit: cover;
888888+ }
889889+890890+ /* Mono mark variant: the SVG is used as a CSS mask and the element's
891891+ background-color carries the theme-aware fill. One file per provider,
892892+ theme colors driven by CSS, no shipping of light/dark pairs. */
893893+ .provider-mark-mono {
894894+ display: block;
895895+ width: 60%;
896896+ height: 60%;
897897+ background-color: var(--color-base-content);
898898+ mask-image: var(--mark-url);
899899+ mask-size: contain;
900900+ mask-repeat: no-repeat;
901901+ mask-position: center;
902902+ -webkit-mask-image: var(--mark-url);
903903+ -webkit-mask-size: contain;
904904+ -webkit-mask-repeat: no-repeat;
905905+ -webkit-mask-position: center;
906906+ }
907907+908908+ .provider-body {
909909+ @apply flex-1 min-w-0 flex flex-col;
910910+ gap: 0.125rem;
911911+ }
912912+913913+ .provider-title-row {
914914+ @apply flex items-baseline gap-2 flex-wrap;
915915+ }
916916+917917+ .provider-domain {
918918+ font-family: var(--font-mono);
919919+ font-weight: 500;
920920+ font-size: 0.975rem;
921921+ letter-spacing: -0.01em;
922922+ color: var(--color-base-content);
923923+ }
924924+925925+ /* Region chip. Tight, instrumenty. Tabular-like letterforms via the
926926+ mono face; flag emoji sits first and uses the system emoji font stack
927927+ to keep rendering consistent across platforms (Windows, Linux, macOS). */
928928+ .provider-chip {
929929+ @apply inline-flex items-center gap-1.5 px-2 py-[0.15rem] rounded-sm;
930930+ font-family: var(--font-mono);
931931+ font-size: 0.7rem;
932932+ font-weight: 500;
933933+ letter-spacing: 0.04em;
934934+ color: color-mix(in oklch, var(--color-base-content) 70%, transparent);
935935+ background: color-mix(in oklch, var(--color-base-300) 55%, transparent);
936936+ }
937937+ .provider-chip-flag {
938938+ font-family:
939939+ "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji",
940940+ "Twemoji Mozilla", "EmojiOne Color", sans-serif;
941941+ font-size: 0.9em;
942942+ line-height: 1;
943943+ letter-spacing: 0;
944944+ }
945945+946946+ .provider-meta {
947947+ @apply flex items-center gap-2 text-xs;
948948+ color: color-mix(in oklch, var(--color-base-content) 55%, transparent);
949949+ }
950950+ .provider-meta a {
951951+ @apply underline-offset-2;
952952+ }
953953+ .provider-meta a:hover {
954954+ @apply underline text-base-content;
955955+ }
956956+ .provider-meta span {
957957+ color: color-mix(in oklch, var(--color-base-content) 30%, transparent);
958958+ }
959959+960960+ .provider-cta {
961961+ @apply shrink-0;
962962+ }
963963+964964+ /* Mobile: avatar + domain on row 1; region/links + CTA on row 2 so the
965965+ CTA gets full width and the row doesn't wrap into 3 lines. */
966966+ @media (max-width: 32rem) {
967967+ .provider-row-inner {
968968+ display: grid;
969969+ grid-template-columns: 2.5rem 1fr;
970970+ grid-template-areas:
971971+ "mark body"
972972+ "cta cta";
973973+ row-gap: 0.85rem;
974974+ }
975975+ .provider-mark { grid-area: mark; }
976976+ .provider-body { grid-area: body; }
977977+ .provider-cta { grid-area: cta; width: 100%; justify-content: center; }
978978+ }
979979+980980+ /* ----------------------------------------
981981+ SIGNUP CONTINUE — handoff mark
982982+ A slightly larger avatar ringed in primary, so the "you are leaving"
983983+ page reads as explicitly about the destination provider.
984984+ ---------------------------------------- */
985985+ .signup-handoff-mark {
986986+ @apply w-16 h-16 rounded-full overflow-hidden bg-base-200;
987987+ @apply flex items-center justify-center;
988988+ box-shadow:
989989+ 0 0 0 1px var(--color-base-300),
990990+ 0 0 0 5px color-mix(in oklch, var(--color-primary) 12%, transparent);
991991+ }
992992+ .signup-handoff-mark img {
993993+ display: block;
994994+ width: 100%;
995995+ height: 100%;
996996+ object-fit: cover;
997997+ }
998998+ .signup-handoff-mark .provider-mark-mono {
999999+ width: 60%;
10001000+ height: 60%;
10011001+ }
8371002}
83810038391004/* ========================================
+7-6
pkg/appview/templates/pages/login.html
···6767 <summary>Not sure if you have an account?</summary>
6868 <div class="sailor-info-body">
6969 <p>
7070- An <strong>Atmosphere Account</strong> is a portable identity on the AT Protocol —
7171- the same network that powers Bluesky, Tangled, and other apps. One account works
7272- across every application built on the protocol.
7070+ An <strong>Atmosphere account</strong> is your personal identity across a whole network
7171+ of apps. Think of it as a digital passport that you own, not locked to any single app
7272+ or company.
7373 </p>
7474 <p>
7575- The easiest way to create one is at
7676- <a href="https://bsky.app" target="_blank" rel="noopener noreferrer" class="link link-primary">bsky.app</a>.
7777- Already have a Bluesky handle? You're all set — use it here.
7575+ It works the way an email address does: the same account signs you into many different
7676+ apps. If you already have an account from a site like Bluesky, Blacksky, or Tangled,
7777+ you're already set. If you don't, you can
7878+ <a href="/signup" class="link link-primary">create one here →</a>
7879 </p>
7980 </div>
8081 </details>
+52
pkg/appview/templates/pages/signup-continue.html
···11+{{ define "signup-continue" }}
22+<!DOCTYPE html>
33+<html lang="en">
44+<head>
55+ {{ template "head" . }}
66+ {{ template "meta" .Meta }}
77+</head>
88+<body>
99+ {{ template "nav-simple" . }}
1010+1111+ <main id="main-content" class="flex-1 flex items-start justify-center px-4 pt-16 sm:pt-24 pb-20">
1212+ <div class="w-full max-w-md">
1313+ <div class="signup-handoff-mark mb-8">
1414+ {{ if .Provider.AvatarMono }}
1515+ <span class="provider-mark-mono" style="--mark-url: url('{{ .Provider.AvatarPath }}')" role="img" aria-label="{{ .Provider.Domain }}"></span>
1616+ {{ else }}
1717+ <img src="{{ .Provider.AvatarPath }}" alt="" width="64" height="64" loading="lazy" decoding="async">
1818+ {{ end }}
1919+ </div>
2020+2121+ <h1 class="text-2xl sm:text-3xl font-display font-semibold tracking-tight leading-[1.15]">
2222+ Create your account on<br>
2323+ <span class="font-mono font-semibold text-primary tracking-tight">{{ .Provider.Domain }}</span>
2424+ </h1>
2525+2626+ <p class="mt-5 text-base-content/75 leading-relaxed">
2727+ You're being sent to
2828+ <span class="font-mono">{{ .Provider.Domain }}</span>
2929+ to create your account.
3030+ The same account works on {{ .ClientShortName }} and every other app on the Atmosphere,
3131+ including Bluesky, Tangled, and dozens more.
3232+ </p>
3333+3434+ <form action="/signup/continue" method="POST" class="mt-8 flex flex-col gap-3"
3535+ onsubmit="var b=this.querySelector('button[type=submit]');if(b){b.disabled=true;b.querySelector('.cta-label').textContent='Redirecting…';}">
3636+ <input type="hidden" name="to" value="{{ .Provider.Domain }}">
3737+ <button type="submit" autofocus class="btn btn-primary btn-lg">
3838+ <span class="cta-label">Continue to {{ .Provider.Domain }}</span>
3939+ {{ icon "arrow-right" "size-4" }}
4040+ </button>
4141+ <a href="/signup" class="btn btn-ghost btn-sm self-start mt-1 -ml-3">
4242+ {{ icon "chevron-left" "size-4" }}
4343+ Pick a different provider
4444+ </a>
4545+ </form>
4646+ </div>
4747+ </main>
4848+4949+ {{ template "footer" . }}
5050+</body>
5151+</html>
5252+{{ end }}