added "members" for an event that help the admins to moderate. members must be invited via email.

This commit is contained in:
Codex Agent
2025-11-09 22:24:40 +01:00
parent 082b78cd43
commit 7ec3db9c59
23 changed files with 836 additions and 101 deletions

View File

@@ -331,10 +331,12 @@ export type EventMember = {
id: number;
name: string;
email: string | null;
role: 'tenant_admin' | 'member' | 'guest' | string;
role: 'tenant_admin' | 'member';
status?: 'pending' | 'active' | 'invited' | string;
joined_at?: string | null;
avatar_url?: string | null;
permissions?: string[] | null;
user_id?: number | null;
};
type EventListResponse = { data?: JsonValue[] };
@@ -815,6 +817,12 @@ function normalizeMember(member: JsonValue): EventMember {
status: member.status ?? 'active',
joined_at: member.joined_at ?? member.created_at ?? null,
avatar_url: member.avatar_url ?? member.avatar ?? null,
permissions: Array.isArray(member.permissions)
? (member.permissions as string[])
: member.permissions
? String(member.permissions).split(',').map((entry) => entry.trim())
: null,
user_id: member.user_id ?? null,
};
}
@@ -1695,7 +1703,10 @@ export async function getEventMembers(eventIdentifier: number | string, page = 1
};
}
export async function inviteEventMember(eventIdentifier: number | string, payload: { email: string; role?: string; name?: string }): Promise<EventMember> {
export async function inviteEventMember(
eventIdentifier: number | string,
payload: { email: string; role: EventMember['role']; name?: string }
): Promise<EventMember> {
const response = await authorizedFetch(
`/api/v1/tenant/events/${encodeURIComponent(String(eventIdentifier))}/members`,
{

View File

@@ -15,16 +15,20 @@ export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated';
export interface TenantProfile {
id: number;
tenant_id: number;
role?: string | null;
name?: string;
slug?: string;
email?: string | null;
event_credits_balance?: number | null;
features?: Record<string, unknown>;
[key: string]: unknown;
}
interface AuthContextValue {
status: AuthStatus;
user: TenantProfile | null;
abilities: string[];
hasAbility: (ability: string) => boolean;
refreshProfile: () => Promise<void>;
logout: (options?: { redirect?: string }) => Promise<void>;
applyToken: (token: string, abilities: string[]) => Promise<void>;
@@ -80,6 +84,7 @@ async function exchangeSessionForToken(): Promise<{ token: string; abilities: st
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 [abilities, setAbilities] = React.useState<string[]>([]);
const queryClient = useQueryClient();
const profileQueryKey = React.useMemo(() => ['tenantProfile'], []);
@@ -88,6 +93,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
invalidateTenantApiCache();
queryClient.removeQueries({ queryKey: profileQueryKey });
setUser(null);
setAbilities([]);
setStatus('unauthenticated');
}, [profileQueryKey, queryClient]);
@@ -127,6 +133,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
: data.user;
setUser(composed ?? null);
setAbilities(Array.isArray(data?.abilities) ? data.abilities : []);
setStatus('authenticated');
} catch (error) {
console.error('[Auth] Failed to refresh profile', error);
@@ -141,8 +148,9 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
}
}, [handleAuthFailure, profileQueryKey, queryClient]);
const applyToken = React.useCallback(async (token: string, abilities: string[]) => {
storePersonalAccessToken(token, abilities);
const applyToken = React.useCallback(async (token: string, tokenAbilities: string[]) => {
storePersonalAccessToken(token, tokenAbilities);
setAbilities(tokenAbilities);
await refreshProfile();
}, [refreshProfile]);
@@ -221,9 +229,11 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
}
}, [handleAuthFailure]);
const hasAbility = React.useCallback((ability: string) => abilities.includes(ability), [abilities]);
const value = React.useMemo<AuthContextValue>(
() => ({ status, user, refreshProfile, logout, applyToken }),
[status, user, refreshProfile, logout, applyToken]
() => ({ status, user, abilities, hasAbility, refreshProfile, logout, applyToken }),
[status, user, abilities, hasAbility, refreshProfile, logout, applyToken]
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;

View File

@@ -18,6 +18,7 @@ import { NotificationCenter } from './NotificationCenter';
import { UserMenu } from './UserMenu';
import { useEventContext } from '../context/EventContext';
import { EventSwitcher, EventMenuBar } from './EventNav';
import { useAuth } from '../auth/context';
type NavItem = {
key: string;
@@ -50,7 +51,7 @@ export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutP
const photosLabel = t('navigation.photos', { defaultValue: 'Fotos' });
const settingsLabel = t('navigation.settings');
const navItems = React.useMemo<NavItem[]>(() => [
const baseNavItems = React.useMemo<NavItem[]>(() => [
{
key: 'dashboard',
to: ADMIN_HOME_PATH,
@@ -85,6 +86,19 @@ export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutP
},
], [eventsLabel, eventsPath, photosPath, photosLabel, settingsLabel, singleEvent, events.length, t]);
const { user } = useAuth();
const isMember = user?.role === 'member';
const navItems = React.useMemo(
() => baseNavItems.filter((item) => {
if (!isMember) {
return true;
}
return !['dashboard', 'settings'].includes(item.key);
}),
[baseNavItems, isMember],
);
const prefetchers = React.useMemo(() => ({
[ADMIN_HOME_PATH]: () =>
Promise.all([

View File

@@ -31,6 +31,8 @@ export function UserMenu() {
const [pendingLocale, setPendingLocale] = React.useState<SupportedLocale | null>(null);
const currentLocale = getCurrentLocale();
const isMember = user?.role === 'member';
const initials = React.useMemo(() => {
if (user?.name) {
return user.name
@@ -94,14 +96,18 @@ export function UserMenu() {
<User className="h-4 w-4" />
{t('navigation.profile', { defaultValue: 'Profil' })}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => goTo(ADMIN_SETTINGS_PATH)}>
<Settings className="h-4 w-4" />
{t('navigation.settings', { defaultValue: 'Einstellungen' })}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => goTo(ADMIN_BILLING_PATH)}>
<CreditCard className="h-4 w-4" />
{t('navigation.billing', { defaultValue: 'Billing' })}
</DropdownMenuItem>
{!isMember && (
<>
<DropdownMenuItem onSelect={() => goTo(ADMIN_SETTINGS_PATH)}>
<Settings className="h-4 w-4" />
{t('navigation.settings', { defaultValue: 'Einstellungen' })}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => goTo(ADMIN_BILLING_PATH)}>
<CreditCard className="h-4 w-4" />
{t('navigation.billing', { defaultValue: 'Billing' })}
</DropdownMenuItem>
</>
)}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuSub>

View File

@@ -26,7 +26,7 @@ import { ADMIN_EVENTS_PATH } from '../constants';
type InviteForm = {
email: string;
name: string;
role: string;
role: EventMember['role'];
};
const emptyInvite: InviteForm = {
@@ -68,7 +68,6 @@ export default function EventMembersPage() {
() => ({
tenant_admin: t('management.members.roles.tenantAdmin', 'Tenant-Admin'),
member: t('management.members.roles.member', 'Mitglied'),
guest: t('management.members.roles.guest', 'Gast'),
}),
[t]
);
@@ -275,6 +274,12 @@ export default function EventMembersPage() {
<Users className="h-4 w-4 text-emerald-500" />
{t('management.members.sections.invite.title', 'Neues Mitglied einladen')}
</h3>
<p className="text-xs text-slate-500">
{t(
'management.members.sections.invite.helper',
'Mitglieder erhalten Zugriff auf Fotomoderation, Aufgaben und QR-Einladungen. Admins steuern zusätzlich Pakete, Abrechnung und Events.'
)}
</p>
<form className="space-y-3 rounded-2xl border border-emerald-100 bg-white/90 p-4 shadow-sm" onSubmit={handleInvite}>
<div className="space-y-2">
<Label htmlFor="invite-email">{t('management.members.form.emailLabel', 'E-Mail')}</Label>
@@ -308,7 +313,6 @@ export default function EventMembersPage() {
<SelectContent>
<SelectItem value="tenant_admin">{roleLabels.tenant_admin}</SelectItem>
<SelectItem value="member">{roleLabels.member}</SelectItem>
<SelectItem value="guest">{roleLabels.guest}</SelectItem>
</SelectContent>
</Select>
</div>

View File

@@ -10,7 +10,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useAuth } from '../auth/context';
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants';
import { ADMIN_DEFAULT_AFTER_LOGIN_PATH, ADMIN_EVENTS_PATH } from '../constants';
import { encodeReturnTo, resolveReturnTarget } from '../lib/returnTo';
import { useMutation } from '@tanstack/react-query';
@@ -48,7 +48,7 @@ type LoginResponse = {
}
export default function LoginPage(): JSX.Element {
const { status, applyToken } = useAuth();
const { status, applyToken, abilities } = useAuth();
const { t } = useTranslation('auth');
const location = useLocation();
const navigate = useNavigate();
@@ -56,7 +56,15 @@ export default function LoginPage(): JSX.Element {
const searchParams = React.useMemo(() => new URLSearchParams(location.search), [location.search]);
const rawReturnTo = searchParams.get('return_to');
const fallbackTarget = ADMIN_DEFAULT_AFTER_LOGIN_PATH;
const computeDefaultAfterLogin = React.useCallback(
(abilityList?: string[]) => {
const source = abilityList ?? abilities;
return source.includes('tenant-admin') ? ADMIN_DEFAULT_AFTER_LOGIN_PATH : ADMIN_EVENTS_PATH;
},
[abilities],
);
const fallbackTarget = computeDefaultAfterLogin();
const { finalTarget } = React.useMemo(
() => resolveReturnTarget(rawReturnTo, fallbackTarget),
[rawReturnTo, fallbackTarget]
@@ -82,7 +90,9 @@ export default function LoginPage(): JSX.Element {
onSuccess: async (data) => {
setError(null);
await applyToken(data.token, data.abilities ?? []);
navigate(finalTarget, { replace: true });
const postLoginFallback = computeDefaultAfterLogin(data.abilities ?? []);
const { finalTarget: successTarget } = resolveReturnTarget(rawReturnTo, postLoginFallback);
navigate(successTarget, { replace: true });
},
});

View File

@@ -4,6 +4,7 @@ import { useAuth } from './auth/context';
import {
ADMIN_BASE_PATH,
ADMIN_DEFAULT_AFTER_LOGIN_PATH,
ADMIN_EVENTS_PATH,
ADMIN_HOME_PATH,
ADMIN_LOGIN_PATH,
ADMIN_LOGIN_START_PATH,
@@ -56,7 +57,7 @@ function RequireAuth() {
}
function LandingGate() {
const { status } = useAuth();
const { status, user } = useAuth();
if (status === 'loading') {
return (
@@ -67,12 +68,23 @@ function LandingGate() {
}
if (status === 'authenticated') {
return <Navigate to={ADMIN_DEFAULT_AFTER_LOGIN_PATH} replace />;
const target = user?.role === 'member' ? ADMIN_EVENTS_PATH : ADMIN_DEFAULT_AFTER_LOGIN_PATH;
return <Navigate to={target} replace />;
}
return <WelcomeTeaserPage />;
}
function RequireAdminAccess({ children }: { children: React.ReactNode }) {
const { user } = useAuth();
if (user?.role === 'member') {
return <Navigate to={ADMIN_EVENTS_PATH} replace />;
}
return <>{children}</>;
}
export const router = createBrowserRouter([
{
path: ADMIN_BASE_PATH,
@@ -86,13 +98,13 @@ export const router = createBrowserRouter([
{
element: <RequireAuth />,
children: [
{ path: 'dashboard', element: <DashboardPage /> },
{ path: 'dashboard', element: <RequireAdminAccess><DashboardPage /></RequireAdminAccess> },
{ path: 'events', element: <EventsPage /> },
{ path: 'events/new', element: <EventFormPage /> },
{ path: 'events/new', element: <RequireAdminAccess><EventFormPage /></RequireAdminAccess> },
{ path: 'events/:slug', element: <EventDetailPage /> },
{ path: 'events/:slug/edit', element: <EventFormPage /> },
{ path: 'events/:slug/edit', element: <RequireAdminAccess><EventFormPage /></RequireAdminAccess> },
{ path: 'events/:slug/photos', element: <EventPhotosPage /> },
{ path: 'events/:slug/members', element: <EventMembersPage /> },
{ path: 'events/:slug/members', element: <RequireAdminAccess><EventMembersPage /></RequireAdminAccess> },
{ path: 'events/:slug/tasks', element: <EventTasksPage /> },
{ path: 'events/:slug/invites', element: <EventInvitesPage /> },
{ path: 'events/:slug/toolkit', element: <EventToolkitPage /> },
@@ -100,14 +112,14 @@ export const router = createBrowserRouter([
{ path: 'tasks', element: <TasksPage /> },
{ path: 'task-collections', element: <TaskCollectionsPage /> },
{ path: 'emotions', element: <EmotionsPage /> },
{ path: 'billing', element: <BillingPage /> },
{ path: 'settings', element: <SettingsPage /> },
{ path: 'billing', element: <RequireAdminAccess><BillingPage /></RequireAdminAccess> },
{ path: 'settings', element: <RequireAdminAccess><SettingsPage /></RequireAdminAccess> },
{ path: 'faq', element: <FaqPage /> },
{ path: 'settings/profile', element: <ProfilePage /> },
{ path: 'welcome', element: <WelcomeLandingPage /> },
{ path: 'welcome/packages', element: <WelcomePackagesPage /> },
{ path: 'welcome/summary', element: <WelcomeOrderSummaryPage /> },
{ path: 'welcome/event', element: <WelcomeEventSetupPage /> },
{ path: 'settings/profile', element: <RequireAdminAccess><ProfilePage /></RequireAdminAccess> },
{ path: 'welcome', element: <RequireAdminAccess><WelcomeLandingPage /></RequireAdminAccess> },
{ path: 'welcome/packages', element: <RequireAdminAccess><WelcomePackagesPage /></RequireAdminAccess> },
{ path: 'welcome/summary', element: <RequireAdminAccess><WelcomeOrderSummaryPage /></RequireAdminAccess> },
{ path: 'welcome/event', element: <RequireAdminAccess><WelcomeEventSetupPage /></RequireAdminAccess> },
],
},
],