+1
-1
.env.example
+1
-1
.env.example
···
2
2
DB_URL="postgres://postgres:postgres@localhost:5432/malfestio_dev?sslmode=disable"
3
3
4
4
# OAuth Client Configuration (Optional - defaults shown)
5
-
APP_URL=http://localhost:3000
5
+
APP_URL=http://127.0.0.1:3000
6
6
APP_NAME=Malfestio
7
7
8
8
# Server Configuration (Optional - defaults shown)
+37
-12
crates/server/src/api/oauth.rs
+37
-12
crates/server/src/api/oauth.rs
···
59
59
Err(crate::repository::oauth::OAuthRepoError::NotFound(did.to_string()))
60
60
}
61
61
62
+
async fn get_token_by_access_token(
63
+
&self, _access_token: &str,
64
+
) -> Result<crate::repository::oauth::StoredToken, crate::repository::oauth::OAuthRepoError> {
65
+
Err(crate::repository::oauth::OAuthRepoError::NotFound(
66
+
"Mock impl".to_string(),
67
+
))
68
+
}
69
+
62
70
async fn update_tokens(
63
71
&self, _did: &str, _access_token: &str, _refresh_token: Option<&str>,
64
72
_expires_at: Option<chrono::DateTime<Utc>>,
···
90
98
/// Query parameters from OAuth callback.
91
99
#[derive(Deserialize)]
92
100
pub struct CallbackQuery {
93
-
pub code: String,
101
+
pub code: Option<String>,
94
102
pub state: String,
95
103
#[serde(default)]
96
104
pub error: Option<String>,
···
150
158
.into_response();
151
159
}
152
160
161
+
let code = match params.code {
162
+
Some(c) => c,
163
+
None => {
164
+
tracing::error!("OAuth callback missing authorization code");
165
+
return Redirect::to("/login?error=missing_code").into_response();
166
+
}
167
+
};
168
+
153
169
tracing::debug!("Retrieving session for state: {}", params.state);
154
170
let session = {
155
171
let sessions = oauth.sessions.read().unwrap();
···
167
183
}
168
184
};
169
185
170
-
match oauth
171
-
.flow
172
-
.exchange_code(¶ms.code, ¶ms.state, &oauth.sessions)
173
-
.await
174
-
{
186
+
match oauth.flow.exchange_code(&code, ¶ms.state, &oauth.sessions).await {
175
187
Ok(tokens) => {
176
188
let did = session.did.clone().unwrap_or_default();
177
189
let pds_url = session.pds_url.unwrap_or_default();
···
180
192
.map(|secs| Utc::now() + Duration::seconds(secs as i64));
181
193
182
194
tracing::info!("Storing tokens for DID: {}", did);
195
+
183
196
if let Err(e) = oauth
184
197
.repo
185
198
.store_tokens(StoreTokensRequest {
···
199
212
}
200
213
201
214
tracing::info!("OAuth flow completed successfully for DID: {}", did);
202
-
Redirect::to(&format!("/login/success?did={}", urlencoding::encode(&did))).into_response()
215
+
216
+
let handle = match oauth.flow.resolve_did(&did).await {
217
+
Ok(identity) => identity.handle.unwrap_or(did.clone()),
218
+
Err(e) => {
219
+
tracing::warn!("Failed to resolve handle for DID {}: {}", did, e);
220
+
did.clone()
221
+
}
222
+
};
223
+
224
+
let fragment = format!(
225
+
"accessJwt={}&refreshJwt={}&did={}&handle={}",
226
+
urlencoding::encode(&tokens.access_token),
227
+
urlencoding::encode(tokens.refresh_token.as_deref().unwrap_or("")),
228
+
urlencoding::encode(&did),
229
+
urlencoding::encode(&handle)
230
+
);
231
+
Redirect::to(&format!("/login/success#{}", fragment)).into_response()
203
232
}
204
233
Err(e) => {
205
234
tracing::error!("Token exchange failed: {}", e);
···
228
257
pub async fn refresh(State(oauth): State<Arc<OAuthState>>, Json(payload): Json<RefreshRequest>) -> impl IntoResponse {
229
258
tracing::info!("Token refresh request for DID: {}", payload.did);
230
259
231
-
// Get stored tokens from database
232
260
tracing::debug!("Retrieving stored tokens from database for DID: {}", payload.did);
233
261
let stored = match oauth.repo.get_tokens(&payload.did).await {
234
262
Ok(t) => {
···
241
269
}
242
270
};
243
271
244
-
// Reconstruct DPoP keypair
245
272
tracing::debug!("Reconstructing DPoP keypair from stored data");
246
273
let dpop_keypair = match stored.dpop_keypair() {
247
274
Some(kp) => kp,
···
255
282
}
256
283
};
257
284
258
-
// Get refresh token
259
285
let refresh_token = match &stored.refresh_token {
260
286
Some(rt) => rt.clone(),
261
287
None => {
···
268
294
}
269
295
};
270
296
271
-
// Refresh tokens via OAuth flow
272
297
match oauth
273
298
.flow
274
299
.refresh_token(&refresh_token, &stored.pds_url, &dpop_keypair)
···
347
372
fn test_callback_query_deserialization() {
348
373
let query = "code=abc123&state=xyz789";
349
374
let parsed: CallbackQuery = serde_qs::from_str(query).unwrap();
350
-
assert_eq!(parsed.code, "abc123");
375
+
assert_eq!(parsed.code, Some("abc123".to_string()));
351
376
assert_eq!(parsed.state, "xyz789");
352
377
assert!(parsed.error.is_none());
353
378
}
+3
-2
crates/server/src/lib.rs
+3
-2
crates/server/src/lib.rs
···
47
47
let pds_url = std::env::var("PDS_URL").unwrap_or_else(|_| "https://bsky.social".to_string());
48
48
let config = state::AppConfig { pds_url };
49
49
let repos = state::Repositories::from(&pool);
50
-
let state = state::AppState::new(pool, repos, config);
51
-
let oauth_state = std::sync::Arc::new(api::oauth::OAuthState::new());
50
+
let state = state::AppState::new(pool.clone(), repos, config);
51
+
let oauth_state = std::sync::Arc::new(api::oauth::OAuthState::with_pool(pool));
52
52
53
53
let auth_routes = Router::new()
54
54
.route("/me", get(api::auth::me))
···
94
94
let oauth_routes = Router::new()
95
95
.route("/authorize", post(api::oauth::authorize))
96
96
.route("/callback", get(api::oauth::callback))
97
+
.route("/client-metadata.json", get(oauth::client_metadata_handler))
97
98
.route("/refresh", post(api::oauth::refresh))
98
99
.with_state(oauth_state.clone());
99
100
+102
-21
crates/server/src/middleware/auth.rs
+102
-21
crates/server/src/middleware/auth.rs
···
124
124
let client = reqwest::Client::new();
125
125
let pds_url = &state.config.pds_url;
126
126
127
-
let resp = client
128
-
.get(format!("{}/xrpc/com.atproto.server.getSession", pds_url))
129
-
.header("Authorization", format!("Bearer {}", token))
130
-
.send()
131
-
.await;
127
+
let lookup_result = state.oauth_repo.get_token_by_access_token(&token).await;
128
+
129
+
if let Err(ref e) = lookup_result {
130
+
tracing::debug!("Token lookup failed: {}", e);
131
+
}
132
+
133
+
let stored_token = lookup_result.ok();
134
+
135
+
let target_pds_url = stored_token
136
+
.as_ref()
137
+
.map(|t| t.pds_url.as_str())
138
+
.unwrap_or(pds_url.as_str());
139
+
140
+
let endpoint_url = format!("{}/xrpc/com.atproto.server.getSession", target_pds_url);
141
+
let mut nonce: Option<String> = None;
142
+
let mut attempt = 0;
143
+
144
+
loop {
145
+
attempt += 1;
146
+
if attempt > 3 {
147
+
tracing::error!("Failed to verify token with PDS after multiple attempts");
148
+
return (
149
+
axum::http::StatusCode::UNAUTHORIZED,
150
+
axum::Json(json!({ "error": "Invalid session" })),
151
+
)
152
+
.into_response();
153
+
}
154
+
155
+
let mut request_builder = client.get(&endpoint_url);
156
+
157
+
if let Some(ref stored) = stored_token {
158
+
if attempt == 1 {
159
+
tracing::debug!("Found stored DPoP token for validation");
160
+
}
161
+
if let Some(dpop_keypair) = stored.dpop_keypair() {
162
+
if attempt == 1 {
163
+
tracing::debug!("Signing PDS request with DPoP Key");
164
+
}
165
+
166
+
let method = "GET";
167
+
let proof = if let Some(ref n) = nonce {
168
+
dpop_keypair.generate_proof_with_nonce(method, &endpoint_url, Some(&token), Some(n))
169
+
} else {
170
+
dpop_keypair.generate_proof(method, &endpoint_url, Some(&token))
171
+
};
172
+
173
+
request_builder = request_builder
174
+
.header("Authorization", format!("DPoP {}", token))
175
+
.header("DPoP", proof);
176
+
} else {
177
+
request_builder = request_builder.header("Authorization", format!("Bearer {}", token));
178
+
}
179
+
} else {
180
+
if attempt == 1 {
181
+
tracing::debug!("No stored DPoP token found, using standard Bearer auth");
182
+
}
183
+
request_builder = request_builder.header("Authorization", format!("Bearer {}", token));
184
+
}
185
+
186
+
let resp = request_builder.send().await;
187
+
188
+
match resp {
189
+
Ok(response) if response.status().is_success() => {
190
+
let body: serde_json::Value = response.json().await.unwrap_or_default();
191
+
let did = body["did"].as_str().unwrap_or("").to_string();
192
+
let handle = body["handle"].as_str().unwrap_or("").to_string();
193
+
let user_ctx = UserContext { did: did.clone(), handle };
132
194
133
-
match resp {
134
-
Ok(response) if response.status().is_success() => {
135
-
let body: serde_json::Value = response.json().await.unwrap_or_default();
136
-
let did = body["did"].as_str().unwrap_or("").to_string();
137
-
let handle = body["handle"].as_str().unwrap_or("").to_string();
138
-
let user_ctx = UserContext { did, handle };
195
+
tracing::debug!("PDS verification successful for DID: {}", did);
196
+
197
+
{
198
+
let mut cache = state.auth_cache.write().await;
199
+
cache.insert(token.to_string(), (user_ctx.clone(), Instant::now()));
200
+
}
139
201
140
-
{
141
-
let mut cache = state.auth_cache.write().await;
142
-
cache.insert(token.to_string(), (user_ctx.clone(), Instant::now()));
202
+
req.extensions_mut().insert(user_ctx);
203
+
return next.run(req).await;
143
204
}
205
+
Ok(response) => {
206
+
let status = response.status();
144
207
145
-
req.extensions_mut().insert(user_ctx);
146
-
next.run(req).await
208
+
if status == axum::http::StatusCode::UNAUTHORIZED
209
+
&& let Some(new_nonce) = response.headers().get("DPoP-Nonce")
210
+
&& let Ok(nonce_str) = new_nonce.to_str()
211
+
{
212
+
tracing::info!("Received DPoP nonce challenge from PDS, retrying verification...");
213
+
nonce = Some(nonce_str.to_string());
214
+
continue;
215
+
}
216
+
217
+
let body = response.text().await.unwrap_or_default();
218
+
tracing::error!("PDS Verification failed. Status: {}, Body: {}", status, body);
219
+
return (
220
+
axum::http::StatusCode::UNAUTHORIZED,
221
+
axum::Json(json!({ "error": "Invalid session", "pds_error": body })),
222
+
)
223
+
.into_response();
224
+
}
225
+
Err(e) => {
226
+
tracing::error!("PDS Request failed: {}", e);
227
+
return (
228
+
axum::http::StatusCode::UNAUTHORIZED,
229
+
axum::Json(json!({ "error": "Invalid session" })),
230
+
)
231
+
.into_response();
232
+
}
147
233
}
148
-
_ => (
149
-
axum::http::StatusCode::UNAUTHORIZED,
150
-
axum::Json(json!({ "error": "Invalid session" })),
151
-
)
152
-
.into_response(),
153
234
}
154
235
}
155
236
+4
-3
crates/server/src/oauth/client_metadata.rs
+4
-3
crates/server/src/oauth/client_metadata.rs
···
30
30
/// Create client metadata from environment variables.
31
31
pub fn from_env() -> Self {
32
32
let app_url = std::env::var("APP_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
33
+
let app_url = app_url.trim_end_matches('/');
33
34
let app_name = std::env::var("APP_NAME").unwrap_or_else(|_| "Malfestio".to_string());
34
35
35
36
Self {
36
-
client_id: format!("{}/oauth/client-metadata.json", app_url),
37
+
client_id: format!("{}/api/oauth/client-metadata.json", app_url),
37
38
application_type: "web".to_string(),
38
39
grant_types: vec!["authorization_code".to_string(), "refresh_token".to_string()],
39
40
scope: "atproto transition:generic".to_string(),
40
41
response_types: vec!["code".to_string()],
41
-
redirect_uris: vec![format!("{}/oauth/callback", app_url)],
42
+
redirect_uris: vec![format!("{}/api/oauth/callback", app_url)],
42
43
client_name: app_name,
43
-
client_uri: app_url,
44
+
client_uri: app_url.to_string(),
44
45
token_endpoint_auth_method: "none".to_string(),
45
46
dpop_bound_access_tokens: true,
46
47
}
+43
-3
crates/server/src/oauth/flow.rs
+43
-3
crates/server/src/oauth/flow.rs
···
58
58
/// Create a new OAuth flow manager.
59
59
pub fn new() -> Self {
60
60
let app_url = std::env::var("APP_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
61
+
let app_url = app_url.trim_end_matches('/');
61
62
62
63
Self {
63
64
resolver: IdentityResolver::new(),
64
65
client: reqwest::Client::new(),
65
-
client_id: format!("{}/oauth/client-metadata.json", app_url),
66
-
redirect_uri: format!("{}/oauth/callback", app_url),
66
+
client_id: format!("{}/api/oauth/client-metadata.json", app_url),
67
+
redirect_uri: format!("{}/api/oauth/callback", app_url),
67
68
}
69
+
}
70
+
71
+
/// Resolve a DID to an identity (including handle).
72
+
pub async fn resolve_did(
73
+
&self, did: &str,
74
+
) -> Result<super::resolver::ResolvedIdentity, super::resolver::ResolveError> {
75
+
self.resolver.resolve_did(did).await
68
76
}
69
77
70
78
/// Start the OAuth flow for a user handle or DID.
···
164
172
.generate_proof("POST", &auth_server.token_endpoint, None);
165
173
166
174
tracing::info!("Sending token exchange request to: {}", auth_server.token_endpoint);
167
-
let response = self
175
+
let mut response = self
168
176
.client
169
177
.post(&auth_server.token_endpoint)
170
178
.header("DPoP", dpop_proof)
···
181
189
tracing::error!("Network error during token exchange: {}", e);
182
190
OAuthFlowError::NetworkError(e.to_string())
183
191
})?;
192
+
193
+
if (response.status().as_u16() == 400 || response.status().as_u16() == 401)
194
+
&& let Some(nonce) = response
195
+
.headers()
196
+
.get("DPoP-Nonce")
197
+
.and_then(|h| h.to_str().ok().map(|s| s.to_string()))
198
+
{
199
+
tracing::info!("Received DPoP nonce, retrying token exchange");
200
+
201
+
let dpop_proof =
202
+
session
203
+
.dpop_keypair
204
+
.generate_proof_with_nonce("POST", &auth_server.token_endpoint, None, Some(&nonce));
205
+
206
+
response = self
207
+
.client
208
+
.post(&auth_server.token_endpoint)
209
+
.header("DPoP", dpop_proof)
210
+
.form(&[
211
+
("grant_type", "authorization_code"),
212
+
("code", code),
213
+
("redirect_uri", &self.redirect_uri),
214
+
("client_id", &self.client_id),
215
+
("code_verifier", &session.code_verifier),
216
+
])
217
+
.send()
218
+
.await
219
+
.map_err(|e| {
220
+
tracing::error!("Network error during retry token exchange: {}", e);
221
+
OAuthFlowError::NetworkError(e.to_string())
222
+
})?;
223
+
}
184
224
185
225
let status = response.status();
186
226
if !status.is_success() {
+2
crates/server/src/oauth/mod.rs
+2
crates/server/src/oauth/mod.rs
+46
crates/server/src/repository/oauth.rs
+46
crates/server/src/repository/oauth.rs
···
76
76
/// Get stored tokens for a user.
77
77
async fn get_tokens(&self, did: &str) -> Result<StoredToken, OAuthRepoError>;
78
78
79
+
/// Get stored tokens by access token.
80
+
async fn get_token_by_access_token(&self, access_token: &str) -> Result<StoredToken, OAuthRepoError>;
81
+
79
82
/// Update tokens after refresh.
80
83
async fn update_tokens(
81
84
&self, did: &str, access_token: &str, refresh_token: Option<&str>, expires_at: Option<DateTime<Utc>>,
···
143
146
.await
144
147
.map_err(|e| OAuthRepoError::DatabaseError(e.to_string()))?
145
148
.ok_or_else(|| OAuthRepoError::NotFound(format!("No tokens for DID: {}", did)))?;
149
+
150
+
Ok(StoredToken {
151
+
did: row.get("did"),
152
+
pds_url: row.get("pds_url"),
153
+
access_token: row.get("access_token"),
154
+
refresh_token: row.get("refresh_token"),
155
+
token_type: row.get("token_type"),
156
+
expires_at: row.get("expires_at"),
157
+
dpop_private_key: row.get("dpop_private_key"),
158
+
created_at: row.get("created_at"),
159
+
updated_at: row.get("updated_at"),
160
+
})
161
+
}
162
+
163
+
async fn get_token_by_access_token(&self, access_token: &str) -> Result<StoredToken, OAuthRepoError> {
164
+
let client = self
165
+
.pool
166
+
.get()
167
+
.await
168
+
.map_err(|e| OAuthRepoError::DatabaseError(e.to_string()))?;
169
+
170
+
let row = client
171
+
.query_opt(
172
+
"SELECT did, pds_url, access_token, refresh_token, token_type, expires_at, dpop_private_key, created_at, updated_at
173
+
FROM oauth_tokens WHERE access_token = $1",
174
+
&[&access_token],
175
+
)
176
+
.await
177
+
.map_err(|e| OAuthRepoError::DatabaseError(e.to_string()))?
178
+
.ok_or_else(|| OAuthRepoError::NotFound("Token not found".to_string()))?;
146
179
147
180
Ok(StoredToken {
148
181
did: row.get("did"),
···
277
310
.find(|t| t.did == did)
278
311
.cloned()
279
312
.ok_or_else(|| OAuthRepoError::NotFound(format!("No tokens for DID: {}", did)))
313
+
}
314
+
315
+
async fn get_token_by_access_token(&self, access_token: &str) -> Result<StoredToken, OAuthRepoError> {
316
+
if *self.should_fail.lock().unwrap() {
317
+
return Err(OAuthRepoError::DatabaseError("Mock failure".to_string()));
318
+
}
319
+
320
+
let tokens = self.tokens.lock().unwrap();
321
+
tokens
322
+
.iter()
323
+
.find(|t| t.access_token == access_token)
324
+
.cloned()
325
+
.ok_or_else(|| OAuthRepoError::NotFound("Token not found".to_string()))
280
326
}
281
327
282
328
async fn update_tokens(
+3
justfile
+3
justfile
+2
web/src/App.tsx
+2
web/src/App.tsx
···
15
15
import LectureImport from "$pages/LectureImport";
16
16
import Library from "$pages/Library";
17
17
import Login from "$pages/Login";
18
+
import LoginSuccess from "$pages/LoginSuccess";
18
19
import NoteNew from "$pages/NoteNew";
19
20
import Notes from "$pages/Notes";
20
21
import NoteView from "$pages/NoteView";
···
58
59
return (
59
60
<Router>
60
61
<Route path="/login" component={Login} />
62
+
<Route path="/login/success" component={LoginSuccess} />
61
63
<Route path="/about" component={About} />
62
64
<Route path="/help" component={Help} />
63
65
<Route path="/" component={ProtectedLayout}>
+11
-9
web/src/components/layout/Header.tsx
+11
-9
web/src/components/layout/Header.tsx
···
11
11
12
12
export const Header: Component = () => {
13
13
return (
14
-
<header class="h-16 border-b border-gray-800 bg-gray-900 flex items-center justify-between px-6 sticky top-0 z-50">
14
+
<header class="h-16 border-b border-gray-800 bg-black flex items-center justify-between px-6 sticky top-0 z-50">
15
15
<div class="flex items-center gap-6">
16
16
<A href="/" class="text-xl font-bold text-white tracking-tight">Malfestio</A>
17
-
<nav class="hidden md:flex items-center gap-4 text-sm font-medium text-gray-400">
18
-
<A href="/home" activeClass="text-blue-500" class="hover:text-white transition-colors">Decks</A>
19
-
<A href="/study" activeClass="text-blue-500" class="hover:text-white transition-colors">Review</A>
20
-
<A href="/discovery" activeClass="text-blue-500" class="hover:text-white transition-colors">Discovery</A>
21
-
<A href="/library" activeClass="text-blue-500" class="hover:text-white transition-colors">Library</A>
22
-
<A href="/feed" activeClass="text-blue-500" class="hover:text-white transition-colors">Feed</A>
23
-
<A href="/settings" activeClass="text-blue-500" class="hover:text-white transition-colors">Settings</A>
24
-
</nav>
17
+
<Show when={authStore.isAuthenticated()}>
18
+
<nav class="hidden md:flex items-center gap-4 text-sm font-medium text-gray-400">
19
+
<A href="/home" activeClass="text-blue-500" class="hover:text-white transition-colors">Decks</A>
20
+
<A href="/study" activeClass="text-blue-500" class="hover:text-white transition-colors">Review</A>
21
+
<A href="/discovery" activeClass="text-blue-500" class="hover:text-white transition-colors">Discovery</A>
22
+
<A href="/library" activeClass="text-blue-500" class="hover:text-white transition-colors">Library</A>
23
+
<A href="/feed" activeClass="text-blue-500" class="hover:text-white transition-colors">Feed</A>
24
+
<A href="/settings" activeClass="text-blue-500" class="hover:text-white transition-colors">Settings</A>
25
+
</nav>
26
+
</Show>
25
27
</div>
26
28
<div class="flex items-center gap-4">
27
29
<Show when={authStore.user()} fallback={<Login />}>
+7
-1
web/src/index.css
+7
-1
web/src/index.css
···
15
15
--font-body: "Figtree Variable", sans-serif;
16
16
17
17
/* Spacing Tokens - 16px grid */
18
+
/*
19
+
FIXME: These conflict with, and break existing usage of classes like max-w-4xl
18
20
--spacing-xs: 4px;
19
21
--spacing-sm: 8px;
20
22
--spacing-md: 12px;
···
23
25
--spacing-xl: 32px;
24
26
--spacing-2xl: 48px;
25
27
--spacing-3xl: 64px;
26
-
--spacing-4xl: 96px;
28
+
--spacing-4xl: 96px; */
27
29
28
30
/* Elevation Layers */
29
31
--layer-00: #161616;
···
181
183
transform: scale(0.97);
182
184
transition: transform var(--duration-instant) var(--easing-sharp);
183
185
}
186
+
187
+
button {
188
+
@apply cursor-pointer disabled:cursor-not-allowed disabled:opacity-50;
189
+
}
+11
-3
web/src/pages/Login.tsx
+11
-3
web/src/pages/Login.tsx
···
1
1
import { AppLayout } from "$components/layout/AppLayout";
2
2
import { api } from "$lib/api";
3
3
import { authStore } from "$lib/store";
4
-
import { useNavigate } from "@solidjs/router";
4
+
import { useNavigate, useSearchParams } from "@solidjs/router";
5
5
import type { Component } from "solid-js";
6
-
import { createSignal } from "solid-js";
6
+
import { createEffect, createSignal } from "solid-js";
7
7
8
8
const Login: Component = () => {
9
9
const [identifier, setIdentifier] = createSignal("");
···
11
11
const [error, setError] = createSignal("");
12
12
const [isLoading, setIsLoading] = createSignal(false);
13
13
const navigate = useNavigate();
14
+
const [searchParams] = useSearchParams();
15
+
16
+
createEffect(() => {
17
+
if (searchParams.error) {
18
+
const desc = searchParams.description ? `: ${searchParams.description}` : "";
19
+
setError(searchParams.error + desc);
20
+
}
21
+
});
14
22
15
23
const handleLogin = async (e: Event) => {
16
24
e.preventDefault();
···
52
60
53
61
return (
54
62
<AppLayout>
55
-
<div class="min-h-[calc(100vh-8rem)] flex items-center justify-center p-4">
63
+
<div class="py-12 flex justify-center p-4">
56
64
<div class="w-full max-w-md bg-[#262626] border border-[#393939] p-8 shadow-lg section-entry">
57
65
<h1 class="text-3xl font-light text-[#F4F4F4] mb-2 tracking-tight">Log in</h1>
58
66
<p class="text-[#C6C6C6] text-sm mb-8 font-light">Continue to Malfestio</p>
+37
web/src/pages/LoginSuccess.tsx
+37
web/src/pages/LoginSuccess.tsx
···
1
+
import { authStore } from "$lib/store";
2
+
import { useNavigate } from "@solidjs/router";
3
+
import { type Component, onMount } from "solid-js";
4
+
5
+
const LoginSuccess: Component = () => {
6
+
const navigate = useNavigate();
7
+
8
+
onMount(() => {
9
+
const hash = window.location.hash.substring(1);
10
+
const params = new URLSearchParams(hash);
11
+
12
+
const accessJwt = params.get("accessJwt");
13
+
const refreshJwt = params.get("refreshJwt");
14
+
const did = params.get("did");
15
+
const handle = params.get("handle");
16
+
17
+
if (accessJwt && did) {
18
+
authStore.login({ accessJwt, refreshJwt: refreshJwt || "", did, handle: handle || did });
19
+
window.history.replaceState(null, "", "/");
20
+
navigate("/");
21
+
} else {
22
+
console.error("Missing tokens in login success");
23
+
navigate("/login?error=missing_tokens");
24
+
}
25
+
});
26
+
27
+
return (
28
+
<div class="flex items-center justify-center h-screen bg-[#161616] text-[#F4F4F4]">
29
+
<div class="flex flex-col items-center gap-4">
30
+
<div class="w-8 h-8 border-t-2 border-[#0F62FE] rounded-full animate-spin" />
31
+
<p class="text-sm text-[#8D8D8D]">Finalizing login...</p>
32
+
</div>
33
+
</div>
34
+
);
35
+
};
36
+
37
+
export default LoginSuccess;
+1
-1
web/src/pages/tests/Login.test.tsx
+1
-1
web/src/pages/tests/Login.test.tsx
···
11
11
12
12
vi.mock("$lib/store", () => ({ authStore: { login: vi.fn() } }));
13
13
14
-
vi.mock("@solidjs/router", () => ({ useNavigate: () => mockNavigate }));
14
+
vi.mock("@solidjs/router", () => ({ useNavigate: () => mockNavigate, useSearchParams: () => [{}, vi.fn()] }));
15
15
16
16
vi.mock(
17
17
"$components/layout/AppLayout",
+73
web/src/pages/tests/LoginSuccess.test.tsx
+73
web/src/pages/tests/LoginSuccess.test.tsx
···
1
+
import { authStore } from "$lib/store";
2
+
import { cleanup, render, waitFor } from "@solidjs/testing-library";
3
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
+
import LoginSuccess from "../LoginSuccess";
5
+
6
+
const { mockNavigate } = vi.hoisted(() => ({ mockNavigate: vi.fn() }));
7
+
8
+
vi.mock("$lib/store", () => ({ authStore: { login: vi.fn() } }));
9
+
vi.mock("@solidjs/router", () => ({ useNavigate: () => mockNavigate }));
10
+
11
+
describe("LoginSuccess Page", () => {
12
+
const originalLocation = window.location;
13
+
14
+
beforeEach(() => {
15
+
vi.stubGlobal("location", {
16
+
configurable: true,
17
+
enumerable: true,
18
+
value: { hash: "", href: "http://localhost/login/success", assign: vi.fn(), replace: vi.fn() },
19
+
});
20
+
vi.spyOn(window.history, "replaceState");
21
+
});
22
+
23
+
afterEach(() => {
24
+
cleanup();
25
+
vi.clearAllMocks();
26
+
vi.stubGlobal("location", originalLocation);
27
+
});
28
+
29
+
it("logs in and redirects on valid tokens", async () => {
30
+
window.location.hash = "#accessJwt=access123&refreshJwt=refresh123&did=did:plc:123&handle=alice.bsky.social";
31
+
32
+
render(() => <LoginSuccess />);
33
+
34
+
await waitFor(() => {
35
+
expect(authStore.login).toHaveBeenCalledWith({
36
+
accessJwt: "access123",
37
+
refreshJwt: "refresh123",
38
+
did: "did:plc:123",
39
+
handle: "alice.bsky.social",
40
+
});
41
+
42
+
expect(window.history.replaceState).toHaveBeenCalledWith(null, "", "/");
43
+
expect(mockNavigate).toHaveBeenCalledWith("/");
44
+
});
45
+
});
46
+
47
+
it("handles missing optional parameters (handle fallback)", async () => {
48
+
window.location.hash = "#accessJwt=access123&refreshJwt=&did=did:plc:123";
49
+
50
+
render(() => <LoginSuccess />);
51
+
52
+
await waitFor(() => {
53
+
expect(authStore.login).toHaveBeenCalledWith({
54
+
accessJwt: "access123",
55
+
refreshJwt: "",
56
+
did: "did:plc:123",
57
+
handle: "did:plc:123",
58
+
});
59
+
expect(mockNavigate).toHaveBeenCalledWith("/");
60
+
});
61
+
});
62
+
63
+
it("redirects to error on missing required tokens", async () => {
64
+
window.location.hash = "#did=did:plc:123";
65
+
66
+
render(() => <LoginSuccess />);
67
+
68
+
await waitFor(() => {
69
+
expect(authStore.login).not.toHaveBeenCalled();
70
+
expect(mockNavigate).toHaveBeenCalledWith("/login?error=missing_tokens");
71
+
});
72
+
});
73
+
});
+34
-16
web/vite.config.ts
+34
-16
web/vite.config.ts
···
1
1
import tailwindcss from "@tailwindcss/vite";
2
2
import path from "path";
3
+
import { loadEnv } from "vite";
3
4
import solid from "vite-plugin-solid";
4
5
import { defineConfig } from "vitest/config";
5
6
6
-
export default defineConfig({
7
-
plugins: [solid(), tailwindcss()],
8
-
resolve: {
9
-
alias: {
10
-
$lib: path.resolve(__dirname, "src/lib"),
11
-
$pages: path.resolve(__dirname, "src/pages"),
12
-
$components: path.resolve(__dirname, "src/components"),
13
-
$ui: path.resolve(__dirname, "src/components/ui"),
7
+
export default defineConfig(({ mode }) => {
8
+
const env = loadEnv(mode, process.cwd(), "");
9
+
const appUrl = env.APP_URL || "http://localhost:3000";
10
+
let host = "localhost";
11
+
try {
12
+
const url = new URL(appUrl);
13
+
host = url.hostname;
14
+
} catch {
15
+
console.warn("Invalid APP_URL in .env, defaulting to localhost");
16
+
}
17
+
18
+
return {
19
+
plugins: [solid(), tailwindcss()],
20
+
resolve: {
21
+
alias: {
22
+
$lib: path.resolve(__dirname, "src/lib"),
23
+
$pages: path.resolve(__dirname, "src/pages"),
24
+
$components: path.resolve(__dirname, "src/components"),
25
+
$ui: path.resolve(__dirname, "src/components/ui"),
26
+
},
14
27
},
15
-
},
16
-
server: { proxy: { "/api": { target: "http://localhost:8080", changeOrigin: true } } },
17
-
test: {
18
-
environment: "jsdom",
19
-
ui: false,
20
-
watch: false,
21
-
server: { deps: { inline: [/@solidjs/, /solid-js/, /solid-motionone/, /motion/] } },
22
-
},
28
+
server: {
29
+
host: "0.0.0.0",
30
+
allowedHosts: [host, "localhost", "127.0.0.1", ".ts.net", ".ngrok-free.app"],
31
+
proxy: { "/api": { target: "http://localhost:8080", changeOrigin: true } },
32
+
port: 3000,
33
+
},
34
+
test: {
35
+
environment: "jsdom",
36
+
ui: false,
37
+
watch: false,
38
+
server: { deps: { inline: [/@solidjs/, /solid-js/, /solid-motionone/, /motion/] } },
39
+
},
40
+
};
23
41
});