feat: implement tenant OAuth flow and guest achievements
This commit is contained in:
@@ -1,98 +1,103 @@
|
||||
export async function login(email: string, password: string): Promise<{ token: string }> {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
const res = await fetch('/api/v1/tenant/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': csrfToken || '',
|
||||
},
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
if (!res.ok) throw new Error('Login failed');
|
||||
return res.json();
|
||||
import { authorizedFetch } from './auth/tokens';
|
||||
|
||||
type JsonValue = Record<string, any>;
|
||||
|
||||
async function jsonOrThrow<T>(response: Response, message: string): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const body = await safeJson(response);
|
||||
console.error('[API]', message, response.status, body);
|
||||
throw new Error(message);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
async function safeJson(response: Response): Promise<JsonValue | null> {
|
||||
try {
|
||||
return (await response.clone().json()) as JsonValue;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getEvents(): Promise<any[]> {
|
||||
const token = localStorage.getItem('ta_token') || '';
|
||||
const res = await fetch('/api/v1/tenant/events', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to load events');
|
||||
const json = await res.json();
|
||||
return json.data ?? [];
|
||||
const response = await authorizedFetch('/api/v1/tenant/events');
|
||||
const data = await jsonOrThrow<{ data?: any[] }>(response, 'Failed to load events');
|
||||
return data.data ?? [];
|
||||
}
|
||||
|
||||
export async function createEvent(payload: { name: string; slug: string; date?: string; is_active?: boolean }): Promise<number> {
|
||||
const token = localStorage.getItem('ta_token') || '';
|
||||
const res = await fetch('/api/v1/tenant/events', {
|
||||
const response = await authorizedFetch('/api/v1/tenant/events', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to create event');
|
||||
const json = await res.json();
|
||||
return json.id;
|
||||
const data = await jsonOrThrow<{ id: number }>(response, 'Failed to create event');
|
||||
return data.id;
|
||||
}
|
||||
|
||||
export async function updateEvent(id: number, payload: Partial<{ name: string; slug: string; date?: string; is_active?: boolean }>): Promise<void> {
|
||||
const token = localStorage.getItem('ta_token') || '';
|
||||
const res = await fetch(`/api/v1/tenant/events/${id}`, {
|
||||
export async function updateEvent(
|
||||
id: number,
|
||||
payload: Partial<{ name: string; slug: string; date?: string; is_active?: boolean }>
|
||||
): Promise<void> {
|
||||
const response = await authorizedFetch(`/api/v1/tenant/events/${id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to update event');
|
||||
if (!response.ok) {
|
||||
await safeJson(response);
|
||||
throw new Error('Failed to update event');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getEventPhotos(id: number): Promise<any[]> {
|
||||
const token = localStorage.getItem('ta_token') || '';
|
||||
const res = await fetch(`/api/v1/tenant/events/${id}/photos`, { headers: { Authorization: `Bearer ${token}` } });
|
||||
if (!res.ok) throw new Error('Failed to load photos');
|
||||
const json = await res.json();
|
||||
return json.data ?? [];
|
||||
const response = await authorizedFetch(`/api/v1/tenant/events/${id}/photos`);
|
||||
const data = await jsonOrThrow<{ data?: any[] }>(response, 'Failed to load photos');
|
||||
return data.data ?? [];
|
||||
}
|
||||
|
||||
export async function featurePhoto(id: number) {
|
||||
const token = localStorage.getItem('ta_token') || '';
|
||||
await fetch(`/api/v1/tenant/photos/${id}/feature`, { method: 'POST', headers: { Authorization: `Bearer ${token}` } });
|
||||
export async function featurePhoto(id: number): Promise<void> {
|
||||
const response = await authorizedFetch(`/api/v1/tenant/photos/${id}/feature`, { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
await safeJson(response);
|
||||
throw new Error('Failed to feature photo');
|
||||
}
|
||||
}
|
||||
|
||||
export async function unfeaturePhoto(id: number) {
|
||||
const token = localStorage.getItem('ta_token') || '';
|
||||
await fetch(`/api/v1/tenant/photos/${id}/unfeature`, { method: 'POST', headers: { Authorization: `Bearer ${token}` } });
|
||||
export async function unfeaturePhoto(id: number): Promise<void> {
|
||||
const response = await authorizedFetch(`/api/v1/tenant/photos/${id}/unfeature`, { method: 'POST' });
|
||||
if (!response.ok) {
|
||||
await safeJson(response);
|
||||
throw new Error('Failed to unfeature photo');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getEvent(id: number): Promise<any> {
|
||||
const token = localStorage.getItem('ta_token') || '';
|
||||
const res = await fetch(`/api/v1/tenant/events/${id}`, { headers: { Authorization: `Bearer ${token}` } });
|
||||
if (!res.ok) throw new Error('Failed to load event');
|
||||
return res.json();
|
||||
const response = await authorizedFetch(`/api/v1/tenant/events/${id}`);
|
||||
return jsonOrThrow<any>(response, 'Failed to load event');
|
||||
}
|
||||
|
||||
export async function toggleEvent(id: number): Promise<boolean> {
|
||||
const token = localStorage.getItem('ta_token') || '';
|
||||
const res = await fetch(`/api/v1/tenant/events/${id}/toggle`, { method: 'POST', headers: { Authorization: `Bearer ${token}` } });
|
||||
if (!res.ok) throw new Error('Failed to toggle');
|
||||
const json = await res.json();
|
||||
return !!json.is_active;
|
||||
const response = await authorizedFetch(`/api/v1/tenant/events/${id}/toggle`, { method: 'POST' });
|
||||
const data = await jsonOrThrow<{ is_active: boolean }>(response, 'Failed to toggle event');
|
||||
return !!data.is_active;
|
||||
}
|
||||
|
||||
export async function getEventStats(id: number): Promise<{ total: number; featured: number; likes: number }> {
|
||||
const token = localStorage.getItem('ta_token') || '';
|
||||
const res = await fetch(`/api/v1/tenant/events/${id}/stats`, { headers: { Authorization: `Bearer ${token}` } });
|
||||
if (!res.ok) throw new Error('Failed to load stats');
|
||||
return res.json();
|
||||
const response = await authorizedFetch(`/api/v1/tenant/events/${id}/stats`);
|
||||
return jsonOrThrow<{ total: number; featured: number; likes: number }>(response, 'Failed to load stats');
|
||||
}
|
||||
|
||||
export async function createInviteLink(id: number): Promise<string> {
|
||||
const token = localStorage.getItem('ta_token') || '';
|
||||
const res = await fetch(`/api/v1/tenant/events/${id}/invites`, { method: 'POST', headers: { Authorization: `Bearer ${token}` } });
|
||||
if (!res.ok) throw new Error('Failed to create invite');
|
||||
const json = await res.json();
|
||||
return json.link as string;
|
||||
const response = await authorizedFetch(`/api/v1/tenant/events/${id}/invites`, { method: 'POST' });
|
||||
const data = await jsonOrThrow<{ link: string }>(response, 'Failed to create invite');
|
||||
return data.link;
|
||||
}
|
||||
|
||||
export async function deletePhoto(id: number) {
|
||||
const token = localStorage.getItem('ta_token') || '';
|
||||
await fetch(`/api/v1/tenant/photos/${id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } });
|
||||
export async function deletePhoto(id: number): Promise<void> {
|
||||
const response = await authorizedFetch(`/api/v1/tenant/photos/${id}`, { method: 'DELETE' });
|
||||
if (!response.ok) {
|
||||
await safeJson(response);
|
||||
throw new Error('Failed to delete photo');
|
||||
}
|
||||
}
|
||||
|
||||
126
resources/js/admin/auth/context.tsx
Normal file
126
resources/js/admin/auth/context.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
authorizedFetch,
|
||||
clearOAuthSession,
|
||||
clearTokens,
|
||||
completeOAuthCallback,
|
||||
isAuthError,
|
||||
loadTokens,
|
||||
registerAuthFailureHandler,
|
||||
startOAuthFlow,
|
||||
} from './tokens';
|
||||
|
||||
export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated';
|
||||
|
||||
export interface TenantProfile {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
email?: string | null;
|
||||
event_credits_balance?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface AuthContextValue {
|
||||
status: AuthStatus;
|
||||
user: TenantProfile | null;
|
||||
login: (redirectPath?: string) => void;
|
||||
logout: (options?: { redirect?: string }) => void;
|
||||
completeLogin: (params: URLSearchParams) => Promise<string | null>;
|
||||
refreshProfile: () => Promise<void>;
|
||||
}
|
||||
|
||||
const AuthContext = React.createContext<AuthContextValue | undefined>(undefined);
|
||||
|
||||
export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [status, setStatus] = React.useState<AuthStatus>('loading');
|
||||
const [user, setUser] = React.useState<TenantProfile | null>(null);
|
||||
|
||||
const handleAuthFailure = React.useCallback(() => {
|
||||
clearTokens();
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const unsubscribe = registerAuthFailureHandler(handleAuthFailure);
|
||||
return unsubscribe;
|
||||
}, [handleAuthFailure]);
|
||||
|
||||
const refreshProfile = React.useCallback(async () => {
|
||||
try {
|
||||
const response = await authorizedFetch('/api/v1/tenant/me');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load profile');
|
||||
}
|
||||
const profile = (await response.json()) as TenantProfile;
|
||||
setUser(profile);
|
||||
setStatus('authenticated');
|
||||
} catch (error) {
|
||||
if (isAuthError(error)) {
|
||||
handleAuthFailure();
|
||||
} else {
|
||||
console.error('[Auth] Failed to refresh profile', error);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}, [handleAuthFailure]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const tokens = loadTokens();
|
||||
if (!tokens) {
|
||||
setStatus('unauthenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
refreshProfile().catch(() => {
|
||||
// refreshProfile already handled failures.
|
||||
});
|
||||
}, [refreshProfile]);
|
||||
|
||||
const login = React.useCallback((redirectPath?: string) => {
|
||||
const target = redirectPath ?? window.location.pathname + window.location.search;
|
||||
startOAuthFlow(target);
|
||||
}, []);
|
||||
|
||||
const logout = React.useCallback(({ redirect }: { redirect?: string } = {}) => {
|
||||
clearTokens();
|
||||
clearOAuthSession();
|
||||
setUser(null);
|
||||
setStatus('unauthenticated');
|
||||
if (redirect) {
|
||||
window.location.href = redirect;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const completeLogin = React.useCallback(
|
||||
async (params: URLSearchParams) => {
|
||||
setStatus('loading');
|
||||
try {
|
||||
const redirectTarget = await completeOAuthCallback(params);
|
||||
await refreshProfile();
|
||||
return redirectTarget;
|
||||
} catch (error) {
|
||||
handleAuthFailure();
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[handleAuthFailure, refreshProfile]
|
||||
);
|
||||
|
||||
const value = React.useMemo<AuthContextValue>(
|
||||
() => ({ status, user, login, logout, completeLogin, refreshProfile }),
|
||||
[status, user, login, logout, completeLogin, refreshProfile]
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
|
||||
export function useAuth(): AuthContextValue {
|
||||
const context = React.useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
16
resources/js/admin/auth/pkce.ts
Normal file
16
resources/js/admin/auth/pkce.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { base64UrlEncode } from './utils';
|
||||
|
||||
export function generateState(): string {
|
||||
return base64UrlEncode(window.crypto.getRandomValues(new Uint8Array(32)));
|
||||
}
|
||||
|
||||
export function generateCodeVerifier(): string {
|
||||
// RFC 7636 recommends a length between 43 and 128 characters.
|
||||
return base64UrlEncode(window.crypto.getRandomValues(new Uint8Array(64)));
|
||||
}
|
||||
|
||||
export async function generateCodeChallenge(verifier: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(verifier);
|
||||
const digest = await window.crypto.subtle.digest('SHA-256', data);
|
||||
return base64UrlEncode(new Uint8Array(digest));
|
||||
}
|
||||
238
resources/js/admin/auth/tokens.ts
Normal file
238
resources/js/admin/auth/tokens.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { generateCodeChallenge, generateCodeVerifier, generateState } from './pkce';
|
||||
import { decodeStoredTokens } from './utils';
|
||||
|
||||
const TOKEN_STORAGE_KEY = 'tenant_oauth_tokens.v1';
|
||||
const CODE_VERIFIER_KEY = 'tenant_oauth_code_verifier';
|
||||
const STATE_KEY = 'tenant_oauth_state';
|
||||
const REDIRECT_KEY = 'tenant_oauth_redirect';
|
||||
const TOKEN_ENDPOINT = '/api/v1/oauth/token';
|
||||
const AUTHORIZE_ENDPOINT = '/api/v1/oauth/authorize';
|
||||
const SCOPES = (import.meta.env.VITE_OAUTH_SCOPES as string | undefined) ?? 'tenant:read tenant:write';
|
||||
|
||||
function getClientId(): string {
|
||||
const clientId = import.meta.env.VITE_OAUTH_CLIENT_ID as string | undefined;
|
||||
if (!clientId) {
|
||||
throw new Error('VITE_OAUTH_CLIENT_ID is not configured');
|
||||
}
|
||||
return clientId;
|
||||
}
|
||||
|
||||
function buildRedirectUri(): string {
|
||||
return new URL('/admin/auth/callback', window.location.origin).toString();
|
||||
}
|
||||
|
||||
export class AuthError extends Error {
|
||||
constructor(public code: 'unauthenticated' | 'unauthorized' | 'invalid_state' | 'token_exchange_failed', message?: string) {
|
||||
super(message ?? code);
|
||||
this.name = 'AuthError';
|
||||
}
|
||||
}
|
||||
|
||||
export function isAuthError(value: unknown): value is AuthError {
|
||||
return value instanceof AuthError;
|
||||
}
|
||||
|
||||
type AuthFailureHandler = () => void;
|
||||
const authFailureHandlers = new Set<AuthFailureHandler>();
|
||||
|
||||
function notifyAuthFailure() {
|
||||
authFailureHandlers.forEach((handler) => {
|
||||
try {
|
||||
handler();
|
||||
} catch (error) {
|
||||
console.error('[Auth] Failure handler threw', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function registerAuthFailureHandler(handler: AuthFailureHandler): () => void {
|
||||
authFailureHandlers.add(handler);
|
||||
return () => {
|
||||
authFailureHandlers.delete(handler);
|
||||
};
|
||||
}
|
||||
|
||||
export interface StoredTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt: number;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export interface TokenResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
expires_in: number;
|
||||
token_type: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
export function loadTokens(): StoredTokens | null {
|
||||
const raw = localStorage.getItem(TOKEN_STORAGE_KEY);
|
||||
const stored = decodeStoredTokens<StoredTokens>(raw);
|
||||
if (!stored) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!stored.accessToken || !stored.refreshToken || !stored.expiresAt) {
|
||||
clearTokens();
|
||||
return null;
|
||||
}
|
||||
|
||||
return stored;
|
||||
}
|
||||
|
||||
export function saveTokens(response: TokenResponse): StoredTokens {
|
||||
const expiresAt = Date.now() + Math.max(response.expires_in - 30, 0) * 1000;
|
||||
const stored: StoredTokens = {
|
||||
accessToken: response.access_token,
|
||||
refreshToken: response.refresh_token,
|
||||
expiresAt,
|
||||
scope: response.scope,
|
||||
};
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, JSON.stringify(stored));
|
||||
return stored;
|
||||
}
|
||||
|
||||
export function clearTokens(): void {
|
||||
localStorage.removeItem(TOKEN_STORAGE_KEY);
|
||||
}
|
||||
|
||||
export async function ensureAccessToken(): Promise<string> {
|
||||
const tokens = loadTokens();
|
||||
if (!tokens) {
|
||||
notifyAuthFailure();
|
||||
throw new AuthError('unauthenticated', 'No tokens available');
|
||||
}
|
||||
|
||||
if (Date.now() < tokens.expiresAt) {
|
||||
return tokens.accessToken;
|
||||
}
|
||||
|
||||
return refreshAccessToken(tokens.refreshToken);
|
||||
}
|
||||
|
||||
async function refreshAccessToken(refreshToken: string): Promise<string> {
|
||||
if (!refreshToken) {
|
||||
notifyAuthFailure();
|
||||
throw new AuthError('unauthenticated', 'Missing refresh token');
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
client_id: getClientId(),
|
||||
});
|
||||
|
||||
const response = await fetch(TOKEN_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[Auth] Refresh token request failed', response.status);
|
||||
notifyAuthFailure();
|
||||
throw new AuthError('unauthenticated', 'Refresh token invalid');
|
||||
}
|
||||
|
||||
const data = (await response.json()) as TokenResponse;
|
||||
const stored = saveTokens(data);
|
||||
return stored.accessToken;
|
||||
}
|
||||
|
||||
export async function authorizedFetch(input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> {
|
||||
const token = await ensureAccessToken();
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
if (!headers.has('Accept')) {
|
||||
headers.set('Accept', 'application/json');
|
||||
}
|
||||
|
||||
const response = await fetch(input, { ...init, headers });
|
||||
if (response.status === 401) {
|
||||
notifyAuthFailure();
|
||||
throw new AuthError('unauthorized', 'Access token rejected');
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function startOAuthFlow(redirectPath?: string): Promise<void> {
|
||||
const verifier = generateCodeVerifier();
|
||||
const state = generateState();
|
||||
const challenge = await generateCodeChallenge(verifier);
|
||||
|
||||
sessionStorage.setItem(CODE_VERIFIER_KEY, verifier);
|
||||
sessionStorage.setItem(STATE_KEY, state);
|
||||
if (redirectPath) {
|
||||
sessionStorage.setItem(REDIRECT_KEY, redirectPath);
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
response_type: 'code',
|
||||
client_id: getClientId(),
|
||||
redirect_uri: buildRedirectUri(),
|
||||
scope: SCOPES,
|
||||
state,
|
||||
code_challenge: challenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
|
||||
window.location.href = `${AUTHORIZE_ENDPOINT}?${params.toString()}`;
|
||||
}
|
||||
|
||||
export async function completeOAuthCallback(params: URLSearchParams): Promise<string | null> {
|
||||
if (params.get('error')) {
|
||||
throw new AuthError('token_exchange_failed', params.get('error_description') ?? params.get('error') ?? 'OAuth error');
|
||||
}
|
||||
|
||||
const code = params.get('code');
|
||||
const returnedState = params.get('state');
|
||||
const verifier = sessionStorage.getItem(CODE_VERIFIER_KEY);
|
||||
const expectedState = sessionStorage.getItem(STATE_KEY);
|
||||
|
||||
if (!code || !verifier || !returnedState || !expectedState || returnedState !== expectedState) {
|
||||
notifyAuthFailure();
|
||||
throw new AuthError('invalid_state', 'PKCE state mismatch');
|
||||
}
|
||||
|
||||
sessionStorage.removeItem(CODE_VERIFIER_KEY);
|
||||
sessionStorage.removeItem(STATE_KEY);
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code,
|
||||
client_id: getClientId(),
|
||||
redirect_uri: buildRedirectUri(),
|
||||
code_verifier: verifier,
|
||||
});
|
||||
|
||||
const response = await fetch(TOKEN_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('[Auth] Authorization code exchange failed', response.status);
|
||||
notifyAuthFailure();
|
||||
throw new AuthError('token_exchange_failed', 'Failed to exchange authorization code');
|
||||
}
|
||||
|
||||
const data = (await response.json()) as TokenResponse;
|
||||
saveTokens(data);
|
||||
|
||||
const redirectTarget = sessionStorage.getItem(REDIRECT_KEY);
|
||||
if (redirectTarget) {
|
||||
sessionStorage.removeItem(REDIRECT_KEY);
|
||||
}
|
||||
|
||||
return redirectTarget;
|
||||
}
|
||||
|
||||
export function clearOAuthSession(): void {
|
||||
sessionStorage.removeItem(CODE_VERIFIER_KEY);
|
||||
sessionStorage.removeItem(STATE_KEY);
|
||||
sessionStorage.removeItem(REDIRECT_KEY);
|
||||
}
|
||||
20
resources/js/admin/auth/utils.ts
Normal file
20
resources/js/admin/auth/utils.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export function base64UrlEncode(buffer: Uint8Array): string {
|
||||
let binary = '';
|
||||
buffer.forEach((byte) => {
|
||||
binary += String.fromCharCode(byte);
|
||||
});
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
export function decodeStoredTokens<T>(value: string | null): T | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(value) as T;
|
||||
} catch (error) {
|
||||
console.warn('[Auth] Failed to parse stored tokens', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import { AuthProvider } from './auth/context';
|
||||
import { router } from './router';
|
||||
import '../../css/app.css';
|
||||
import { initializeTheme } from '@/hooks/use-appearance';
|
||||
@@ -9,7 +10,8 @@ initializeTheme();
|
||||
const rootEl = document.getElementById('root')!;
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
<AuthProvider>
|
||||
<RouterProvider router={router} />
|
||||
</AuthProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
|
||||
35
resources/js/admin/pages/AuthCallbackPage.tsx
Normal file
35
resources/js/admin/pages/AuthCallbackPage.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/context';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
|
||||
export default function AuthCallbackPage() {
|
||||
const { completeLogin } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
completeLogin(params)
|
||||
.then((redirectTo) => {
|
||||
navigate(redirectTo ?? '/admin', { replace: true });
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[Auth] Callback processing failed', err);
|
||||
if (isAuthError(err) && err.code === 'token_exchange_failed') {
|
||||
setError('Anmeldung fehlgeschlagen. Bitte versuche es erneut.');
|
||||
} else if (isAuthError(err) && err.code === 'invalid_state') {
|
||||
setError('Ungueltiger Login-Vorgang. Bitte starte die Anmeldung erneut.');
|
||||
} else {
|
||||
setError('Unbekannter Fehler beim Login.');
|
||||
}
|
||||
});
|
||||
}, [completeLogin, navigate]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-3 p-6 text-center text-sm text-muted-foreground">
|
||||
<span className="text-base font-medium text-foreground">Anmeldung wird verarbeitet ...</span>
|
||||
{error && <div className="max-w-sm rounded border border-red-300 bg-red-50 p-3 text-sm text-red-700">{error}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { getEvent, getEventStats, toggleEvent, createInviteLink } from '../api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { createInviteLink, getEvent, getEventStats, toggleEvent } from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
|
||||
export default function EventDetailPage() {
|
||||
const [sp] = useSearchParams();
|
||||
@@ -11,35 +12,67 @@ export default function EventDetailPage() {
|
||||
const [stats, setStats] = React.useState<{ total: number; featured: number; likes: number } | null>(null);
|
||||
const [invite, setInvite] = React.useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
const e = await getEvent(id);
|
||||
setEv(e);
|
||||
setStats(await getEventStats(id));
|
||||
}
|
||||
React.useEffect(() => { load(); }, [id]);
|
||||
const load = React.useCallback(async () => {
|
||||
try {
|
||||
const event = await getEvent(id);
|
||||
setEv(event);
|
||||
setStats(await getEventStats(id));
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
async function onToggle() {
|
||||
const isActive = await toggleEvent(id);
|
||||
setEv((o: any) => ({ ...(o || {}), is_active: isActive }));
|
||||
try {
|
||||
const isActive = await toggleEvent(id);
|
||||
setEv((previous: any) => ({ ...(previous || {}), is_active: isActive }));
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function onInvite() {
|
||||
const link = await createInviteLink(id);
|
||||
setInvite(link);
|
||||
try { await navigator.clipboard.writeText(link); } catch {}
|
||||
try {
|
||||
const link = await createInviteLink(id);
|
||||
setInvite(link);
|
||||
try {
|
||||
await navigator.clipboard.writeText(link);
|
||||
} catch {
|
||||
// clipboard may be unavailable
|
||||
}
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!ev) return <div className="p-4">Lade…</div>;
|
||||
const joinLink = `${location.origin}/e/${ev.slug}`;
|
||||
if (!ev) {
|
||||
return <div className="p-4">Lade ...</div>;
|
||||
}
|
||||
|
||||
const joinLink = `${window.location.origin}/e/${ev.slug}`;
|
||||
const qrUrl = `/admin/qr?data=${encodeURIComponent(joinLink)}`;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl p-4 space-y-4">
|
||||
<div className="mx-auto max-w-3xl space-y-4 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-lg font-semibold">Event: {renderName(ev.name)}</h1>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={onToggle}>{ev.is_active ? 'Deaktivieren' : 'Aktivieren'}</Button>
|
||||
<Button variant="secondary" onClick={() => nav(`/admin/events/photos?id=${id}`)}>Fotos moderieren</Button>
|
||||
<Button variant="secondary" onClick={onToggle}>
|
||||
{ev.is_active ? 'Deaktivieren' : 'Aktivieren'}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => nav(`/admin/events/photos?id=${id}`)}>
|
||||
Fotos moderieren
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded border p-3 text-sm">
|
||||
@@ -47,31 +80,45 @@ export default function EventDetailPage() {
|
||||
<div>Datum: {ev.date ?? '-'}</div>
|
||||
<div>Status: {ev.is_active ? 'Aktiv' : 'Inaktiv'}</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3 text-center text-sm">
|
||||
<div className="rounded border p-3"><div className="text-2xl font-semibold">{stats?.total ?? 0}</div><div>Fotos</div></div>
|
||||
<div className="rounded border p-3"><div className="text-2xl font-semibold">{stats?.featured ?? 0}</div><div>Featured</div></div>
|
||||
<div className="rounded border p-3"><div className="text-2xl font-semibold">{stats?.likes ?? 0}</div><div>Likes gesamt</div></div>
|
||||
<div className="grid grid-cols-1 gap-3 text-center text-sm sm:grid-cols-3">
|
||||
<StatCard label="Fotos" value={stats?.total ?? 0} />
|
||||
<StatCard label="Featured" value={stats?.featured ?? 0} />
|
||||
<StatCard label="Likes gesamt" value={stats?.likes ?? 0} />
|
||||
</div>
|
||||
<div className="rounded border p-3">
|
||||
<div className="mb-2 text-sm font-medium">Join-Link</div>
|
||||
<div className="rounded border p-3 text-sm">
|
||||
<div className="mb-2 font-medium">Join-Link</div>
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<input className="w-full rounded border p-2 text-sm" value={joinLink} readOnly />
|
||||
<Button variant="secondary" onClick={() => navigator.clipboard.writeText(joinLink)}>Kopieren</Button>
|
||||
<Button variant="secondary" onClick={() => navigator.clipboard.writeText(joinLink)}>
|
||||
Kopieren
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mb-2 text-sm font-medium">QR</div>
|
||||
<div className="mb-2 font-medium">QR</div>
|
||||
<img src={qrUrl} alt="QR" width={200} height={200} className="rounded border" />
|
||||
<div className="mt-3">
|
||||
<Button variant="secondary" onClick={onInvite}>Einladungslink erzeugen</Button>
|
||||
{invite && <div className="mt-2 text-xs text-muted-foreground">Erzeugt und kopiert: {invite}</div>}
|
||||
<Button variant="secondary" onClick={onInvite}>
|
||||
Einladungslink erzeugen
|
||||
</Button>
|
||||
{invite && (
|
||||
<div className="mt-2 text-xs text-muted-foreground">Erzeugt und kopiert: {invite}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="rounded border p-3">
|
||||
<div className="text-2xl font-semibold">{value}</div>
|
||||
<div>{label}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderName(name: any): string {
|
||||
if (typeof name === 'string') return name;
|
||||
if (name && (name.de || name.en)) return name.de || name.en;
|
||||
return JSON.stringify(name);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { createEvent, updateEvent } from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
export default function EventFormPage() {
|
||||
@@ -13,10 +14,12 @@ export default function EventFormPage() {
|
||||
const [date, setDate] = React.useState('');
|
||||
const [active, setActive] = React.useState(true);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const isEdit = !!id;
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (isEdit) {
|
||||
await updateEvent(Number(id), { name, slug, date, is_active: active });
|
||||
@@ -24,21 +27,31 @@ export default function EventFormPage() {
|
||||
await createEvent({ name, slug, date, is_active: active });
|
||||
}
|
||||
nav('/admin/events');
|
||||
} finally { setSaving(false); }
|
||||
} catch (e) {
|
||||
if (!isAuthError(e)) {
|
||||
setError('Speichern fehlgeschlagen');
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md p-4 space-y-3">
|
||||
<div className="mx-auto max-w-md space-y-3 p-4">
|
||||
<h1 className="text-lg font-semibold">{isEdit ? 'Event bearbeiten' : 'Neues Event'}</h1>
|
||||
{error && <div className="rounded border border-red-300 bg-red-50 p-2 text-sm text-red-700">{error}</div>}
|
||||
<Input placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />
|
||||
<Input placeholder="Slug" value={slug} onChange={(e) => setSlug(e.target.value)} />
|
||||
<Input placeholder="Datum" type="date" value={date} onChange={(e) => setDate(e.target.value)} />
|
||||
<label className="flex items-center gap-2 text-sm"><input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} /> Aktiv</label>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input type="checkbox" checked={active} onChange={(e) => setActive(e.target.checked)} /> Aktiv
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={save} disabled={saving || !name || !slug}>{saving ? 'Speichern…' : 'Speichern'}</Button>
|
||||
<Button onClick={save} disabled={saving || !name || !slug}>
|
||||
{saving ? 'Speichern <20>' : 'Speichern'}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => nav(-1)}>Abbrechen</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { deletePhoto, featurePhoto, getEventPhotos, unfeaturePhoto } from '../api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { deletePhoto, featurePhoto, getEventPhotos, unfeaturePhoto } from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
|
||||
export default function EventPhotosPage() {
|
||||
const [sp] = useSearchParams();
|
||||
@@ -9,33 +10,83 @@ export default function EventPhotosPage() {
|
||||
const [rows, setRows] = React.useState<any[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
|
||||
async function load() {
|
||||
const load = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
try { setRows(await getEventPhotos(id)); } finally { setLoading(false); }
|
||||
}
|
||||
React.useEffect(() => { load(); }, [id]);
|
||||
try {
|
||||
setRows(await getEventPhotos(id));
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
console.error(error);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
async function onFeature(p: any) { await featurePhoto(p.id); load(); }
|
||||
async function onUnfeature(p: any) { await unfeaturePhoto(p.id); load(); }
|
||||
async function onDelete(p: any) { await deletePhoto(p.id); load(); }
|
||||
React.useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
async function onFeature(photo: any) {
|
||||
try {
|
||||
await featurePhoto(photo.id);
|
||||
await load();
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function onUnfeature(photo: any) {
|
||||
try {
|
||||
await unfeaturePhoto(photo.id);
|
||||
await load();
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(photo: any) {
|
||||
try {
|
||||
await deletePhoto(photo.id);
|
||||
await load();
|
||||
} catch (error) {
|
||||
if (!isAuthError(error)) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-5xl p-4">
|
||||
<h1 className="mb-3 text-lg font-semibold">Fotos moderieren</h1>
|
||||
{loading && <div>Lade…</div>}
|
||||
{loading && <div>Lade ...</div>}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
|
||||
{rows.map((p) => (
|
||||
<div key={p.id} className="rounded border p-2">
|
||||
<img src={p.thumbnail_path || p.file_path} className="mb-2 aspect-square w-full rounded object-cover" />
|
||||
<img
|
||||
src={p.thumbnail_path || p.file_path}
|
||||
className="mb-2 aspect-square w-full rounded object-cover"
|
||||
alt={p.caption ?? 'Foto'}
|
||||
/>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span>❤ {p.likes_count}</span>
|
||||
<span>?? {p.likes_count}</span>
|
||||
<div className="flex gap-1">
|
||||
{p.is_featured ? (
|
||||
<Button size="sm" variant="secondary" onClick={() => onUnfeature(p)}>Unfeature</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => onUnfeature(p)}>
|
||||
Unfeature
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" variant="secondary" onClick={() => onFeature(p)}>Feature</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => onFeature(p)}>
|
||||
Feature
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="destructive" onClick={() => onDelete(p)}>Löschen</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => onDelete(p)}>
|
||||
L<EFBFBD>schen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,4 +95,3 @@ export default function EventPhotosPage() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React from 'react';
|
||||
import { getEvents } from '../api';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { getEvents } from '../api';
|
||||
import { isAuthError } from '../auth/tokens';
|
||||
|
||||
export default function EventsPage() {
|
||||
const [rows, setRows] = React.useState<any[]>([]);
|
||||
@@ -11,7 +12,15 @@ export default function EventsPage() {
|
||||
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
try { setRows(await getEvents()); } catch (e) { setError('Laden fehlgeschlagen'); } finally { setLoading(false); }
|
||||
try {
|
||||
setRows(await getEvents());
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setError('Laden fehlgeschlagen');
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
@@ -20,24 +29,32 @@ export default function EventsPage() {
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h1 className="text-lg font-semibold">Meine Events</h1>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" onClick={() => nav('/admin/events/new')}>Neues Event</Button>
|
||||
<Link to="/admin/settings"><Button variant="secondary">Einstellungen</Button></Link>
|
||||
<Button variant="secondary" onClick={() => nav('/admin/events/new')}>
|
||||
Neues Event
|
||||
</Button>
|
||||
<Link to="/admin/settings">
|
||||
<Button variant="secondary">Einstellungen</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
{loading && <div>Lade…</div>}
|
||||
{error && <div className="rounded border border-red-300 bg-red-50 p-2 text-sm text-red-700">{error}</div>}
|
||||
{loading && <div>Lade ...</div>}
|
||||
{error && (
|
||||
<div className="rounded border border-red-300 bg-red-50 p-2 text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
<div className="divide-y rounded border">
|
||||
{rows.map((e) => (
|
||||
<div key={e.id} className="flex items-center justify-between p-3">
|
||||
{rows.map((event) => (
|
||||
<div key={event.id} className="flex items-center justify-between p-3">
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">{renderName(e.name)}</div>
|
||||
<div className="text-muted-foreground">Slug: {e.slug} · Datum: {e.date ?? '-'}</div>
|
||||
<div className="font-medium">{renderName(event.name)}</div>
|
||||
<div className="text-muted-foreground">Slug: {event.slug} <EFBFBD> Datum: {event.date ?? '-'}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link to={`/admin/events/view?id=${e.id}`} className="text-sm underline">details</Link>
|
||||
<Link to={`/admin/events/edit?id=${e.id}`} className="text-sm underline">bearbeiten</Link>
|
||||
<Link to={`/admin/events/photos?id=${e.id}`} className="text-sm underline">fotos</Link>
|
||||
<a className="text-sm underline" href={`/e/${e.slug}`} target="_blank">öffnen</a>
|
||||
<div className="flex items-center gap-2 text-sm underline">
|
||||
<Link to={`/admin/events/view?id=${event.id}`}>details</Link>
|
||||
<Link to={`/admin/events/edit?id=${event.id}`}>bearbeiten</Link>
|
||||
<Link to={`/admin/events/photos?id=${event.id}`}>fotos</Link>
|
||||
<a href={`/e/${event.slug}`} target="_blank" rel="noreferrer">
|
||||
<EFBFBD>ffnen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,43 +1,61 @@
|
||||
import React from 'react';
|
||||
import { login } from '../api';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Location, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
|
||||
import { useAuth } from '../auth/context';
|
||||
|
||||
interface LocationState {
|
||||
from?: Location;
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const nav = useNavigate();
|
||||
const [email, setEmail] = React.useState('');
|
||||
const [password, setPassword] = React.useState('');
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const { status, login } = useAuth();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const searchParams = React.useMemo(() => new URLSearchParams(location.search), [location.search]);
|
||||
const oauthError = searchParams.get('error');
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
const { token } = await login(email, password);
|
||||
localStorage.setItem('ta_token', token);
|
||||
nav('/admin', { replace: true });
|
||||
} catch (err: any) {
|
||||
setError('Login fehlgeschlagen');
|
||||
} finally { setLoading(false); }
|
||||
}
|
||||
React.useEffect(() => {
|
||||
if (status === 'authenticated') {
|
||||
navigate('/admin', { replace: true });
|
||||
}
|
||||
}, [status, navigate]);
|
||||
|
||||
const redirectTarget = React.useMemo(() => {
|
||||
const state = location.state as LocationState | null;
|
||||
if (state?.from) {
|
||||
const from = state.from;
|
||||
const search = from.search ?? '';
|
||||
const hash = from.hash ?? '';
|
||||
return `${from.pathname}${search}${hash}`;
|
||||
}
|
||||
return '/admin';
|
||||
}, [location.state]);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-sm p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="mx-auto flex min-h-screen max-w-sm flex-col justify-center p-6">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="text-lg font-semibold">Tenant Admin</h1>
|
||||
<AppearanceToggleDropdown />
|
||||
</div>
|
||||
<form onSubmit={submit} className="space-y-3">
|
||||
{error && <div className="rounded border border-red-300 bg-red-50 p-2 text-sm text-red-700">{error}</div>}
|
||||
<Input placeholder="E-Mail" type="email" value={email} onChange={(e) => setEmail(e.target.value)} />
|
||||
<Input placeholder="Passwort" type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
<Button type="submit" disabled={loading || !email || !password} className="w-full">{loading ? 'Bitte warten…' : 'Anmelden'}</Button>
|
||||
</form>
|
||||
<div className="space-y-4 text-sm text-muted-foreground">
|
||||
<p>
|
||||
Melde dich mit deinem Fotospiel-Account an. Du wirst zur sicheren OAuth-Anmeldung weitergeleitet und danach
|
||||
wieder zur Admin-Oberfl<EFBFBD>che gebracht.
|
||||
</p>
|
||||
{oauthError && (
|
||||
<div className="rounded border border-red-300 bg-red-50 p-2 text-sm text-red-700">
|
||||
Anmeldung fehlgeschlagen: {oauthError}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={status === 'loading'}
|
||||
onClick={() => login(redirectTarget)}
|
||||
>
|
||||
{status === 'loading' ? 'Bitte warten <20>' : 'Mit Tenant-Account anmelden'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,22 +2,34 @@ import React from 'react';
|
||||
import AppearanceToggleDropdown from '@/components/appearance-dropdown';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../auth/context';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const nav = useNavigate();
|
||||
function logout() {
|
||||
localStorage.removeItem('ta_token');
|
||||
nav('/admin/login', { replace: true });
|
||||
const { user, logout } = useAuth();
|
||||
|
||||
function handleLogout() {
|
||||
logout({ redirect: '/admin/login' });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-sm p-6">
|
||||
<h1 className="mb-4 text-lg font-semibold">Einstellungen</h1>
|
||||
<div className="mb-4">
|
||||
<div className="mx-auto max-w-sm space-y-4 p-6">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Einstellungen</h1>
|
||||
{user && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Angemeldet als {user.name ?? user.email ?? 'Tenant Admin'} - Tenant #{user.tenant_id}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-medium">Darstellung</div>
|
||||
<AppearanceToggleDropdown />
|
||||
</div>
|
||||
<Button variant="destructive" onClick={logout}>Abmelden</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="destructive" onClick={handleLogout}>Abmelden</Button>
|
||||
<Button variant="secondary" onClick={() => nav(-1)}>Zurück</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,36 @@
|
||||
import React from 'react';
|
||||
import { createBrowserRouter, Outlet, Navigate } from 'react-router-dom';
|
||||
import { createBrowserRouter, Outlet, Navigate, useLocation } from 'react-router-dom';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import EventsPage from './pages/EventsPage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
import EventFormPage from './pages/EventFormPage';
|
||||
import EventPhotosPage from './pages/EventPhotosPage';
|
||||
import EventDetailPage from './pages/EventDetailPage';
|
||||
import AuthCallbackPage from './pages/AuthCallbackPage';
|
||||
import { useAuth } from './auth/context';
|
||||
|
||||
function RequireAuth() {
|
||||
const token = localStorage.getItem('ta_token');
|
||||
if (!token) return <Navigate to="/admin/login" replace />;
|
||||
const { status } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
|
||||
Bitte warten <EFBFBD>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
return <Navigate to="/admin/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{ path: '/admin/login', element: <LoginPage /> },
|
||||
{ path: '/admin/auth/callback', element: <AuthCallbackPage /> },
|
||||
{
|
||||
path: '/admin',
|
||||
element: <RequireAuth />,
|
||||
|
||||
Reference in New Issue
Block a user