added "members" for an event that help the admins to moderate. members must be invited via email.
This commit is contained in:
@@ -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`,
|
||||
{
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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> },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user