Skip to content

Commit 268cefc

Browse files
committed
add dev autologin configuration and update login flow
1 parent dd5727a commit 268cefc

5 files changed

Lines changed: 162 additions & 43 deletions

File tree

.env.local.example

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Copy this file to .env.local and fill in the values to enable auto-login in development mode.
2+
VITE_DEV_AUTOLOGIN=true
3+
VITE_DEV_AUTOLOGIN_SERVER=demo.gamevau.lt
4+
VITE_DEV_AUTOLOGIN_USERNAME=demo
5+
VITE_DEV_AUTOLOGIN_PASSWORD=demodemo

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,22 @@ Persistence keys:
4141
- `app_refresh_token` (refresh token)
4242
- `app_server_url` (last used server base URL)
4343

44+
## Dev Autologin
45+
46+
Create `gamevault-frontend/.env.local` for dev-only automatic basic login:
47+
48+
```env
49+
VITE_DEV_AUTOLOGIN=true
50+
VITE_DEV_AUTOLOGIN_SERVER=https://example.gamevault.tld
51+
VITE_DEV_AUTOLOGIN_USERNAME=devuser
52+
VITE_DEV_AUTOLOGIN_PASSWORD=devpassword
53+
```
54+
55+
- `.env.local` stays local because `*.local` already ignored.
56+
- Autologin runs only in Vite dev mode and only when no refresh token exists yet.
57+
- Login page reuses stored server URL, so failed refresh or failed dev autologin still lands on right server.
58+
- Restart `pnpm dev` after changing env file.
59+
4460
Uncheck "Remember me" on login if you want the refresh token removed right after authenticating (session-only access token).
4561

4662
If backend endpoints change, update the paths in `AuthContext` and the admin hook.

src/components/Login.tsx

Lines changed: 27 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,39 @@
11
import { useAuth } from "@/context/AuthContext";
2+
import {
3+
AUTH_SERVER_STORAGE_KEY,
4+
getDevAutologinConfig,
5+
normalizeServerUrl,
6+
} from "@/utils/authConfig";
27
import { Logo } from "@components/Logo";
38
import { Button } from "@tw/button";
49
import { Checkbox, CheckboxField } from "@tw/checkbox";
510
import { Field, Label } from "@tw/fieldset";
611
import { Heading } from "@tw/heading";
712
import { Input } from "@tw/input";
813
import { Strong, Text, TextLink } from "@tw/text";
9-
import { FormEvent, useCallback, useEffect, useRef, useState } from "react";
14+
import { useEffect, useRef, useState, type SyntheticEvent } from "react";
1015
import { useNavigate } from "react-router";
1116
import ThemeSwitch from "./ThemeSwitch";
1217
import { Status } from "../api";
1318

1419
export function Login() {
1520
const { loginBasic, loginWithTokens, loading, error } = useAuth();
1621
const navigate = useNavigate();
17-
const [server, setServer] = useState(window.location.origin);
18-
const [confirmedServer, setConfirmedServer] = useState<string | null>(null);
19-
const [username, setUsername] = useState("");
20-
const [password, setPassword] = useState("");
22+
const devAutologin = getDevAutologinConfig();
23+
const [server, setServer] = useState(() => {
24+
return (
25+
localStorage.getItem(AUTH_SERVER_STORAGE_KEY) ||
26+
devAutologin?.server ||
27+
window.location.origin
28+
);
29+
});
30+
const [confirmedServer, setConfirmedServer] = useState<string | null>(() => {
31+
const initialServer =
32+
localStorage.getItem(AUTH_SERVER_STORAGE_KEY) || devAutologin?.server;
33+
return initialServer ? normalizeServerUrl(initialServer) : null;
34+
});
35+
const [username, setUsername] = useState(() => devAutologin?.username || "");
36+
const [password, setPassword] = useState(() => devAutologin?.password || "");
2137
const [useSso, setUseSso] = useState(false);
2238
const [serverStatus, setServerStatus] = useState<Status | null>(null);
2339
const [statusLoading, setStatusLoading] = useState(false);
@@ -42,25 +58,13 @@ export function Login() {
4258
}
4359
}, [confirmedServer]);
4460

45-
const normalizeServer = useCallback((raw: string) => {
46-
if (!raw) return raw;
47-
let s = raw.trim();
48-
// If user omitted protocol, assume https
49-
if (!/^https?:\/\//i.test(s)) {
50-
s = `https://${s}`;
51-
}
52-
// Remove trailing slashes
53-
s = s.replace(/\/+$/, "");
54-
return s;
55-
}, []);
56-
5761
useEffect(() => {
5862
if (!confirmedServer) {
5963
setServerStatus(null);
6064
setStatusError(false);
6165
return;
6266
}
63-
const normalized = normalizeServer(confirmedServer);
67+
const normalized = normalizeServerUrl(confirmedServer);
6468
if (!normalized) {
6569
setServerStatus(null);
6670
return;
@@ -98,7 +102,7 @@ export function Login() {
98102
return () => {
99103
controller.abort();
100104
};
101-
}, [confirmedServer, normalizeServer]);
105+
}, [confirmedServer]);
102106

103107
useEffect(() => {
104108
if (serverStatus) {
@@ -154,11 +158,10 @@ export function Login() {
154158
}
155159
}, [loginWithTokens, navigate]);
156160

157-
const handleContinue = (e: React.FormEvent) => {
161+
const handleContinue = (e: SyntheticEvent<HTMLFormElement, SubmitEvent>) => {
158162
e.preventDefault();
159163
if (!server.trim()) return;
160-
let normalized = server.trim();
161-
if (!/^https?:\/\//i.test(normalized)) normalized = `https://${normalized}`;
164+
const normalized = normalizeServerUrl(server);
162165
setServer(normalized);
163166
setConfirmedServer(normalized);
164167
};
@@ -168,10 +171,10 @@ export function Login() {
168171
setServerStatus(null);
169172
};
170173

171-
const onSubmit = async (e: FormEvent) => {
174+
const onSubmit = async (e: SyntheticEvent<HTMLFormElement, SubmitEvent>) => {
172175
e.preventDefault();
173176
try {
174-
const normalized = normalizeServer(confirmedServer || server);
177+
const normalized = normalizeServerUrl(confirmedServer || server);
175178
if (useSso) {
176179
window.location.href = `${normalized}/api/auth/oauth2/login`;
177180
return;

src/context/AuthContext.tsx

Lines changed: 78 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@ import {
99
} from "react";
1010
import { GamevaultUser } from "../api";
1111
import { AuthTokens } from "../types/AuthTokens";
12+
import {
13+
AUTH_REFRESH_STORAGE_KEY,
14+
AUTH_SERVER_STORAGE_KEY,
15+
getDevAutologinConfig,
16+
normalizeServerUrl,
17+
} from "@/utils/authConfig";
1218

1319
interface LoginArgs {
1420
server: string;
@@ -38,9 +44,6 @@ interface AuthContextValue {
3844

3945
const AuthContext = createContext<AuthContextValue | null>(null);
4046

41-
const REFRESH_KEY = "app_refresh_token";
42-
const SERVER_KEY = "app_server_url";
43-
4447
interface InternalJwtPayload {
4548
exp?: number;
4649
Exp?: number;
@@ -161,7 +164,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
161164
}, []);
162165
const performRefresh = useCallback(async () => {
163166
const refreshToken =
164-
authRef.current?.refresh_token || localStorage.getItem(REFRESH_KEY);
167+
authRef.current?.refresh_token ||
168+
localStorage.getItem(AUTH_REFRESH_STORAGE_KEY);
165169
if (!refreshToken) throw new Error("Missing refresh token");
166170
const data = await refreshWithToken(refreshToken);
167171
if (!data?.access_token)
@@ -170,7 +174,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
170174
authRef.current = merged;
171175
setAuth(merged);
172176
if (merged.refresh_token)
173-
localStorage.setItem(REFRESH_KEY, merged.refresh_token);
177+
localStorage.setItem(
178+
AUTH_REFRESH_STORAGE_KEY,
179+
merged.refresh_token,
180+
);
174181
nextTokenRefreshRef.current = computeNextTokenRefresh(merged.access_token);
175182
}, []);
176183
const ensureFreshToken = useCallback(async () => {
@@ -210,17 +217,20 @@ export function AuthProvider({ children }: { children: ReactNode }) {
210217
setAuth(null);
211218
authRef.current = null;
212219
nextTokenRefreshRef.current = null;
213-
serverRef.current = (server || "").replace(/\/+$/, "");
220+
serverRef.current = normalizeServerUrl(server);
214221
setServerUrl(serverRef.current);
215-
localStorage.setItem(SERVER_KEY, serverRef.current);
222+
localStorage.setItem(AUTH_SERVER_STORAGE_KEY, serverRef.current);
216223
try {
217224
if (!server || !username || !password)
218225
throw new Error("All fields are required.");
219226
const authData = await loginBasicRequest(username, password);
220227
authRef.current = authData;
221228
setAuth(authData);
222229
if (authData.refresh_token)
223-
localStorage.setItem(REFRESH_KEY, authData.refresh_token);
230+
localStorage.setItem(
231+
AUTH_REFRESH_STORAGE_KEY,
232+
authData.refresh_token,
233+
);
224234
nextTokenRefreshRef.current = authData.access_token
225235
? computeNextTokenRefresh(authData.access_token)
226236
: new Date();
@@ -247,15 +257,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
247257
setAuth(null);
248258
authRef.current = null;
249259
nextTokenRefreshRef.current = null;
250-
serverRef.current = (server || "").replace(/\/+$/, "");
260+
serverRef.current = normalizeServerUrl(server);
251261
setServerUrl(serverRef.current);
252-
localStorage.setItem(SERVER_KEY, serverRef.current);
262+
localStorage.setItem(AUTH_SERVER_STORAGE_KEY, serverRef.current);
253263
try {
254264
if (!tokens?.access_token) throw new Error("Missing access token");
255265
authRef.current = tokens;
256266
setAuth(tokens);
257267
if (tokens.refresh_token)
258-
localStorage.setItem(REFRESH_KEY, tokens.refresh_token);
268+
localStorage.setItem(
269+
AUTH_REFRESH_STORAGE_KEY,
270+
tokens.refresh_token,
271+
);
259272
nextTokenRefreshRef.current = computeNextTokenRefresh(
260273
tokens.access_token,
261274
);
@@ -284,13 +297,57 @@ export function AuthProvider({ children }: { children: ReactNode }) {
284297
bootstrapRanRef.current = true;
285298

286299
(async () => {
287-
const storedRefresh = localStorage.getItem(REFRESH_KEY);
288-
const storedServer = localStorage.getItem(SERVER_KEY);
300+
const storedRefresh = localStorage.getItem(AUTH_REFRESH_STORAGE_KEY);
301+
const storedServer = localStorage.getItem(AUTH_SERVER_STORAGE_KEY);
289302
if (!storedRefresh || !storedServer) {
290-
setBootstrapping(false);
303+
const devAutologin = getDevAutologinConfig();
304+
if (!devAutologin) {
305+
setBootstrapping(false);
306+
return;
307+
}
308+
309+
setLoading(true);
310+
setError(null);
311+
serverRef.current = devAutologin.server;
312+
setServerUrl(serverRef.current);
313+
localStorage.setItem(AUTH_SERVER_STORAGE_KEY, serverRef.current);
314+
try {
315+
const tokens = await loginBasicRequest(
316+
devAutologin.username,
317+
devAutologin.password,
318+
);
319+
if (!tokens.access_token) {
320+
throw new Error("No access_token in dev autologin response");
321+
}
322+
authRef.current = tokens;
323+
setAuth(tokens);
324+
if (tokens.refresh_token) {
325+
localStorage.setItem(
326+
AUTH_REFRESH_STORAGE_KEY,
327+
tokens.refresh_token,
328+
);
329+
}
330+
nextTokenRefreshRef.current = computeNextTokenRefresh(
331+
tokens.access_token,
332+
);
333+
const me = await fetchCurrentUser();
334+
setUser(me);
335+
} catch (e) {
336+
authRef.current = null;
337+
setAuth(null);
338+
setUser(null);
339+
nextTokenRefreshRef.current = null;
340+
localStorage.removeItem(AUTH_REFRESH_STORAGE_KEY);
341+
setError(
342+
e instanceof Error ? e.message : "Dev autologin failed.",
343+
);
344+
} finally {
345+
setLoading(false);
346+
setBootstrapping(false);
347+
}
291348
return;
292349
}
293-
serverRef.current = storedServer.replace(/\/+$/, "");
350+
serverRef.current = normalizeServerUrl(storedServer);
294351
setServerUrl(serverRef.current);
295352
try {
296353
const tokens = await refreshWithToken(storedRefresh);
@@ -299,15 +356,17 @@ export function AuthProvider({ children }: { children: ReactNode }) {
299356
authRef.current = tokens;
300357
setAuth(tokens);
301358
if (tokens.refresh_token)
302-
localStorage.setItem(REFRESH_KEY, tokens.refresh_token);
359+
localStorage.setItem(
360+
AUTH_REFRESH_STORAGE_KEY,
361+
tokens.refresh_token,
362+
);
303363
nextTokenRefreshRef.current = computeNextTokenRefresh(
304364
tokens.access_token,
305365
);
306366
const me = await fetchCurrentUser();
307367
setUser(me);
308368
} catch {
309-
localStorage.removeItem(REFRESH_KEY);
310-
localStorage.removeItem(SERVER_KEY);
369+
localStorage.removeItem(AUTH_REFRESH_STORAGE_KEY);
311370
authRef.current = null;
312371
setAuth(null);
313372
} finally {
@@ -322,7 +381,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
322381
setAuth(null);
323382
setUser(null);
324383
setError(null);
325-
localStorage.removeItem(REFRESH_KEY);
384+
localStorage.removeItem(AUTH_REFRESH_STORAGE_KEY);
326385
}, []);
327386

328387
const refreshCurrentUser = useCallback(async () => {

src/utils/authConfig.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
export const AUTH_REFRESH_STORAGE_KEY = "app_refresh_token";
2+
export const AUTH_SERVER_STORAGE_KEY = "app_server_url";
3+
4+
export interface DevAutologinConfig {
5+
server: string;
6+
username: string;
7+
password: string;
8+
}
9+
10+
const truthyEnvPattern = /^(1|true|yes|on)$/i;
11+
12+
export function normalizeServerUrl(raw: string): string {
13+
let normalized = (raw || "").trim();
14+
if (!normalized) return "";
15+
if (!/^https?:\/\//i.test(normalized)) {
16+
normalized = `https://${normalized}`;
17+
}
18+
return normalized.replace(/\/+$/, "");
19+
}
20+
21+
export function getDevAutologinConfig(): DevAutologinConfig | null {
22+
if (!import.meta.env.DEV) return null;
23+
if (!truthyEnvPattern.test(import.meta.env.VITE_DEV_AUTOLOGIN ?? "")) {
24+
return null;
25+
}
26+
27+
const server = normalizeServerUrl(
28+
import.meta.env.VITE_DEV_AUTOLOGIN_SERVER ?? "",
29+
);
30+
const username = (import.meta.env.VITE_DEV_AUTOLOGIN_USERNAME ?? "").trim();
31+
const password = import.meta.env.VITE_DEV_AUTOLOGIN_PASSWORD ?? "";
32+
33+
if (!server || !username || !password) return null;
34+
35+
return { server, username, password };
36+
}

0 commit comments

Comments
 (0)