forked from
tranquil.farm/tranquil-pds
Our Personal Data Server from scratch!
6.0 kB
219 lines
1<script lang="ts">
2 import { _ } from '../lib/i18n'
3 import { getFullUrl } from '../lib/router.svelte'
4 import { routes } from '../lib/types/routes'
5 import { toast } from '../lib/toast.svelte'
6 import AccountTypeSwitcher from '../components/AccountTypeSwitcher.svelte'
7 import SsoIcon from '../components/SsoIcon.svelte'
8 import { ensureRequestUri, getRequestUriFromUrl, getOAuthRequestUri } from '../lib/oauth'
9
10 interface SsoProvider {
11 provider: string
12 name: string
13 icon: string
14 }
15
16 let providers = $state<SsoProvider[]>([])
17 let loading = $state(true)
18 let initiating = $state<string | null>(null)
19 let initialized = false
20
21 $effect(() => {
22 if (!initialized) {
23 initialized = true
24 ensureRequestUri().then((requestUri) => {
25 if (!requestUri) return
26 fetchProviders()
27 }).catch((err) => {
28 console.error('Failed to ensure OAuth request URI:', err)
29 toast.error($_('common.error'))
30 })
31 }
32 })
33
34 async function fetchProviders() {
35 try {
36 const response = await fetch('/oauth/sso/providers')
37 if (response.ok) {
38 const data = await response.json()
39 providers = (data.providers || []).toSorted((a: SsoProvider, b: SsoProvider) => a.name.localeCompare(b.name))
40 }
41 } catch (err) {
42 console.error('Failed to fetch SSO providers:', err)
43 toast.error(err instanceof Error ? err.message : $_('common.error'))
44 } finally {
45 loading = false
46 }
47 }
48
49 async function initiateRegistration(provider: string) {
50 initiating = provider
51 let requestUri = getRequestUriFromUrl()
52
53 try {
54 let response = await fetch('/oauth/sso/initiate', {
55 method: 'POST',
56 headers: {
57 'Content-Type': 'application/json',
58 'Accept': 'application/json',
59 },
60 body: JSON.stringify({
61 provider,
62 action: 'register',
63 request_uri: requestUri,
64 }),
65 })
66
67 let data = await response.json()
68
69 if (!response.ok) {
70 console.log('SSO initiate failed, restarting OAuth flow', data)
71 try {
72 const newRequestUri = await getOAuthRequestUri('create')
73 const url = new URL(window.location.href)
74 url.searchParams.set('request_uri', newRequestUri)
75 window.location.href = url.toString()
76 } catch (e) {
77 console.error('Failed to restart OAuth flow:', e)
78 toast.error(data.message || data.error || $_('common.error'))
79 initiating = null
80 }
81 return
82 }
83
84 if (data.redirect_url) {
85 window.location.href = data.redirect_url
86 return
87 }
88
89 toast.error($_('common.error'))
90 initiating = null
91 } catch (err) {
92 console.error('SSO registration initiation failed:', err)
93 toast.error(err instanceof Error ? err.message : $_('common.error'))
94 initiating = null
95 }
96 }
97</script>
98
99<div class="page">
100 <header class="page-header">
101 <h1>{$_('register.title')}</h1>
102 <p class="subtitle">{$_('register.ssoSubtitle')}</p>
103 </header>
104
105 <div class="migrate-callout">
106 <div class="migrate-icon">↗</div>
107 <div class="migrate-content">
108 <strong>{$_('register.migrateTitle')}</strong>
109 <p>{$_('register.migrateDescription')}</p>
110 <a href={getFullUrl(routes.migrate)} class="migrate-link">
111 {$_('register.migrateLink')} →
112 </a>
113 </div>
114 </div>
115
116 <AccountTypeSwitcher active="sso" ssoAvailable={providers.length > 0} oauthRequestUri={getRequestUriFromUrl()} />
117
118 {#if loading}
119 <div class="loading">
120 <div class="spinner md"></div>
121 </div>
122 {:else if providers.length === 0}
123 <div class="no-providers">
124 <p>{$_('register.noSsoProviders')}</p>
125 </div>
126 {:else}
127 <div class="provider-list">
128 <p class="provider-hint">{$_('register.ssoHint')}</p>
129 <div class="provider-grid">
130 {#each providers as provider}
131 <button
132 class="provider-button"
133 onclick={() => initiateRegistration(provider.provider)}
134 disabled={initiating !== null}
135 >
136 <SsoIcon provider={provider.provider} size={24} />
137 <span class="provider-name">
138 {#if initiating === provider.provider}
139 {$_('common.loading')}
140 {:else}
141 {$_('register.continueWith', { values: { provider: provider.name } })}
142 {/if}
143 </span>
144 </button>
145 {/each}
146 </div>
147 </div>
148 {/if}
149
150 <div class="form-links">
151 <p class="link-text">
152 {$_('register.alreadyHaveAccount')} <a href={getFullUrl(routes.login)}>{$_('register.signIn')}</a>
153 </p>
154 </div>
155</div>
156
157<style>
158 .no-providers {
159 text-align: center;
160 padding: var(--space-8);
161 color: var(--text-secondary);
162 }
163
164 .provider-list {
165 display: flex;
166 flex-direction: column;
167 gap: var(--space-3);
168 max-width: var(--width-md);
169 }
170
171 .provider-hint {
172 color: var(--text-secondary);
173 font-size: var(--text-sm);
174 margin: 0 0 var(--space-4) 0;
175 }
176
177 .provider-grid {
178 display: grid;
179 grid-template-columns: 1fr;
180 gap: var(--space-3);
181 }
182
183 @media (min-width: 500px) {
184 .provider-grid {
185 grid-template-columns: repeat(2, 1fr);
186 }
187 }
188
189 .provider-button {
190 display: flex;
191 align-items: center;
192 gap: var(--space-3);
193 padding: var(--space-4);
194 background: var(--bg-card);
195 border: 1px solid var(--border-dark);
196 border-radius: var(--radius-lg);
197 cursor: pointer;
198 transition: all var(--transition-normal);
199 font-size: var(--text-base);
200 font-weight: var(--font-medium);
201 color: var(--text-primary);
202 text-align: left;
203 width: 100%;
204 }
205
206 .provider-button:hover:not(:disabled) {
207 background: var(--bg-secondary);
208 border-color: var(--accent);
209 }
210
211 .provider-button:disabled {
212 opacity: 0.6;
213 cursor: not-allowed;
214 }
215
216 .provider-button .provider-name {
217 flex: 1;
218 }
219</style>