Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/kamp-us/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"dependencies": {
"@base-ui/react": "catalog:",
"@radix-ui/colors": "catalog:",
"graphql-ws": "catalog:",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-relay": "catalog:",
Expand Down
127 changes: 71 additions & 56 deletions apps/kamp-us/src/auth/AuthContext.tsx
Original file line number Diff line number Diff line change
@@ -1,87 +1,102 @@
import {createContext, useContext, useState, useCallback, type ReactNode} from "react";
import {createContext, type ReactNode, useCallback, useContext, useState} from "react";
import {resetSubscriptionClient} from "../relay/environment";

interface User {
id: string;
email: string;
name?: string;
id: string;
email: string;
name?: string;
}

interface AuthState {
user: User | null;
token: string | null;
user: User | null;
token: string | null;
}

interface AuthContextValue extends AuthState {
login: (user: User, token: string) => void;
logout: () => void;
isAuthenticated: boolean;
login: (user: User, token: string) => void;
logout: () => void;
isAuthenticated: boolean;
}

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

const STORAGE_KEY = "kampus_auth";

function loadAuthState(): AuthState {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
return JSON.parse(stored);
}
} catch {
// Ignore parse errors
}
return {user: null, token: null};
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
return JSON.parse(stored);
}
} catch {
// Ignore parse errors
}
return {user: null, token: null};
}

function saveAuthState(state: AuthState) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
}

function clearAuthState() {
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(STORAGE_KEY);
}

export function AuthProvider({children}: {children: ReactNode}) {
const [authState, setAuthState] = useState<AuthState>(loadAuthState);

const login = useCallback((user: User, token: string) => {
const newState = {user, token};
setAuthState(newState);
saveAuthState(newState);
}, []);

const logout = useCallback(() => {
setAuthState({user: null, token: null});
clearAuthState();
}, []);

const value: AuthContextValue = {
...authState,
login,
logout,
isAuthenticated: !!authState.token,
};

return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
const [authState, setAuthState] = useState<AuthState>(loadAuthState);

const login = useCallback((user: User, token: string) => {
const newState = {user, token};
setAuthState(newState);
saveAuthState(newState);
}, []);

const logout = useCallback(() => {
setAuthState({user: null, token: null});
clearAuthState();
resetSubscriptionClient();
}, []);

const value: AuthContextValue = {
...authState,
login,
logout,
isAuthenticated: !!authState.token,
};

return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
const context = useContext(AuthContext);
if (!context) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}

export function getStoredToken(): string | null {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
return parsed.token || null;
}
} catch {
// Ignore
}
return null;
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
return parsed.token || null;
}
} catch {
// Ignore
}
return null;
}

export function getStoredUserId(): string | null {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
return parsed.user?.id || null;
}
} catch {
// Ignore
}
return null;
}
24 changes: 24 additions & 0 deletions apps/kamp-us/src/lib/websocket.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {getStoredUserId} from "../auth/AuthContext";

/**
* Constructs the WebSocket URL for GraphQL subscriptions.
*
* In development, connects directly to the backend worker on port 8787.
* In production, uses the same host (proxied through kamp-us worker).
*
* SECURITY: Token is passed via connectionParams (in connection_init message),
* NOT in the URL. The user ID in the URL is used for routing only (not secret).
* The backend validates the token from connectionParams matches the routed user.
*/
export function getWebSocketUrl(): string {
const userId = getStoredUserId();
// User ID is used for routing only - token is validated in connectionParams
const userParam = userId ? `?userId=${encodeURIComponent(userId)}` : "";

if (import.meta.env.DEV) {
return `ws://localhost:8787/graphql${userParam}`;
}

// Always use WSS in production for security
return `wss://${window.location.host}/graphql${userParam}`;
}
Loading