Initial commit
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import { useAccountStore } from '@/stores/accountStore';
|
||||
import { useUIStore } from '@/stores/uiStore';
|
||||
import { fetchSession, fetchSchema, fetchAccountInfo } from '@/services/jmap/client';
|
||||
import { setLocale } from '@/i18n';
|
||||
import { TopBar } from '@/components/layout/TopBar';
|
||||
import { Sidebar } from '@/components/layout/Sidebar';
|
||||
import { MainContent } from '@/components/layout/MainContent';
|
||||
import { ErrorBoundary } from '@/components/layout/ErrorBoundary';
|
||||
import { BootstrapWizard } from '@/components/bootstrap/BootstrapWizard';
|
||||
import {
|
||||
findFirstAccessibleLinkInLayout,
|
||||
findFirstVisibleLinkInLayout,
|
||||
isLinkAccessible,
|
||||
visibleLayouts,
|
||||
} from '@/lib/layout';
|
||||
import { usePermissions } from '@/hooks/usePermissions';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
export default function AdminPanel() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { section, '*': splat } = useParams<{
|
||||
section?: string;
|
||||
'*'?: string;
|
||||
}>();
|
||||
|
||||
let viewName: string | undefined;
|
||||
let id: string | undefined;
|
||||
if (splat) {
|
||||
const parts = splat.split('/');
|
||||
if (parts[parts.length - 1] === 'new') {
|
||||
id = 'new';
|
||||
viewName = parts.slice(0, -1).join('/');
|
||||
} else if (parts[parts.length - 1] === 'singleton') {
|
||||
id = 'singleton';
|
||||
viewName = parts.slice(0, -1).join('/');
|
||||
} else {
|
||||
const schemaStore = useSchemaStore.getState();
|
||||
const fullAsView = parts.join('/');
|
||||
if (schemaStore.schema?.objects[fullAsView]) {
|
||||
viewName = fullAsView;
|
||||
} else if (parts.length > 1) {
|
||||
const candidateView = parts.slice(0, -1).join('/');
|
||||
if (schemaStore.schema?.objects[candidateView]) {
|
||||
viewName = candidateView;
|
||||
id = parts[parts.length - 1];
|
||||
} else {
|
||||
viewName = fullAsView;
|
||||
}
|
||||
} else {
|
||||
viewName = fullAsView;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isSchemaLoaded = useSchemaStore((s) => s.isLoaded);
|
||||
const schema = useSchemaStore((s) => s.schema);
|
||||
const setSchema = useSchemaStore((s) => s.setSchema);
|
||||
const setAccountInfo = useAccountStore((s) => s.setAccountInfo);
|
||||
const edition = useAccountStore((s) => s.edition);
|
||||
const hasObjectPermission = useAccountStore((s) => s.hasObjectPermission);
|
||||
const setSession = useAuthStore((s) => s.setSession);
|
||||
const accessToken = useAuthStore((s) => s.accessToken);
|
||||
const setActiveSection = useUIStore((s) => s.setActiveSection);
|
||||
const sidebarOpen = useUIStore((s) => s.sidebarOpen);
|
||||
const { canViewObject } = usePermissions();
|
||||
|
||||
const [initError, setInitError] = useState<string | null>(null);
|
||||
const [initializing, setInitializing] = useState(!isSchemaLoaded);
|
||||
|
||||
useEffect(() => {
|
||||
const bypassToken = import.meta.env.VITE_ACCESS_TOKEN;
|
||||
if (bypassToken && !accessToken) {
|
||||
useAuthStore.getState().setTokens(bypassToken, '', 86400, '');
|
||||
}
|
||||
}, [accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isSchemaLoaded) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
const session = await fetchSession();
|
||||
const accounts: Record<string, { name: string; isPersonal: boolean }> = {};
|
||||
for (const [accountId, info] of Object.entries(
|
||||
session.accounts as Record<string, { name: string; isPersonal: boolean }>,
|
||||
)) {
|
||||
accounts[accountId] = { name: info.name, isPersonal: info.isPersonal };
|
||||
}
|
||||
const primaryAccounts = session.primaryAccounts as Record<string, string>;
|
||||
const primaryAccountId =
|
||||
primaryAccounts['urn:ietf:params:jmap:core'] ?? Object.values(primaryAccounts)[0] ?? Object.keys(accounts)[0];
|
||||
const apiUrl = (session.apiUrl as string) || '/jmap';
|
||||
|
||||
const capabilities = session.capabilities as Record<string, Record<string, unknown>> | undefined;
|
||||
const coreCapability = capabilities?.['urn:ietf:params:jmap:core'];
|
||||
const maxObjectsInGet =
|
||||
typeof coreCapability?.maxObjectsInGet === 'number' ? coreCapability.maxObjectsInGet : undefined;
|
||||
const maxObjectsInSet =
|
||||
typeof coreCapability?.maxObjectsInSet === 'number' ? coreCapability.maxObjectsInSet : undefined;
|
||||
|
||||
if (cancelled) return;
|
||||
setSession(accounts, primaryAccountId, apiUrl, maxObjectsInGet, maxObjectsInSet);
|
||||
|
||||
const [schemaData, accountData] = await Promise.all([fetchSchema(), fetchAccountInfo()]);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
setSchema(schemaData);
|
||||
|
||||
setAccountInfo(accountData.permissions, accountData.edition, accountData.locale);
|
||||
setLocale(accountData.locale);
|
||||
|
||||
setInitializing(false);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
console.error('Initialization failed:', err);
|
||||
setInitError(err instanceof Error ? err.message : t('errors.unexpectedError'));
|
||||
setInitializing(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isSchemaLoaded, setSession, setSchema, setAccountInfo, t]);
|
||||
|
||||
const hasPermission = useAccountStore((s) => s.hasPermission);
|
||||
const isBootstrapMode = useMemo(() => {
|
||||
if (!schema) return false;
|
||||
if (!canViewObject('x:Bootstrap')) return false;
|
||||
const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get');
|
||||
const hasPerm = (perm: string) => hasPermission(perm);
|
||||
return visibleLayouts(schema, edition, canGet, hasPerm).length === 0;
|
||||
}, [schema, canViewObject, edition, hasObjectPermission, hasPermission]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!schema) return;
|
||||
if (isBootstrapMode) return;
|
||||
const canGet = (prefix: string) => hasObjectPermission(prefix, 'Get');
|
||||
const hasPerm = (perm: string) => hasPermission(perm);
|
||||
const layouts = visibleLayouts(schema, edition, canGet, hasPerm);
|
||||
const pickDefault = (): { layoutName: string; link: string | null } | null => {
|
||||
for (const layout of layouts) {
|
||||
const link = findFirstAccessibleLinkInLayout(schema, layout, edition, canGet, hasPerm);
|
||||
if (link) return { layoutName: layout.name, link };
|
||||
}
|
||||
if (layouts[0]) {
|
||||
return {
|
||||
layoutName: layouts[0].name,
|
||||
link: findFirstVisibleLinkInLayout(schema, layouts[0], edition, canGet, hasPerm),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
if (section && viewName && !isLinkAccessible(schema, viewName, edition, canGet, hasPerm)) {
|
||||
const fallback = pickDefault();
|
||||
if (fallback && (fallback.layoutName !== section || fallback.link !== viewName)) {
|
||||
setActiveSection(fallback.layoutName);
|
||||
if (fallback.link) {
|
||||
navigate(`/${fallback.layoutName}/${fallback.link}`, { replace: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (section) {
|
||||
setActiveSection(section);
|
||||
return;
|
||||
}
|
||||
|
||||
const target = pickDefault();
|
||||
if (target) {
|
||||
setActiveSection(target.layoutName);
|
||||
if (target.link) {
|
||||
navigate(`/${target.layoutName}/${target.link}`, { replace: true });
|
||||
}
|
||||
}
|
||||
}, [
|
||||
section,
|
||||
viewName,
|
||||
schema,
|
||||
setActiveSection,
|
||||
navigate,
|
||||
edition,
|
||||
hasObjectPermission,
|
||||
hasPermission,
|
||||
isBootstrapMode,
|
||||
]);
|
||||
|
||||
if (initializing) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-muted-foreground">{t('common.loading')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (initError) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h2 className="text-xl font-semibold text-destructive">{t('errors.loadSchemaFailed')}</h2>
|
||||
<p className="mt-2 text-muted-foreground">{initError}</p>
|
||||
<button
|
||||
className="mt-4 rounded bg-primary px-4 py-2 text-primary-foreground hover:opacity-90"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
{t('common.continue')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!schema) return null;
|
||||
|
||||
if (isBootstrapMode) {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<BootstrapWizard />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<TopBar />
|
||||
<div className="flex flex-1">
|
||||
<Sidebar />
|
||||
<main
|
||||
className={`flex-1 overflow-auto bg-content-background p-6 transition-all ${sidebarOpen ? 'md:ml-64' : ''}`}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<MainContent viewName={viewName} id={id} section={section} />
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { type FormEvent, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowRight, Loader2 } from 'lucide-react';
|
||||
|
||||
import Logo from '@/components/common/Logo';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { startAuthFlow } from '@/services/auth/oauth';
|
||||
|
||||
export default function LoginPage() {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const originalPath = (location.state as { from?: string } | null)?.from ?? null;
|
||||
const [username, setUsername] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
const trimmed = username.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await startAuthFlow(trimmed, originalPath);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t('login.error', 'An unexpected error occurred'));
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-content-background px-4">
|
||||
<Card className="w-full max-w-sm shadow-sm">
|
||||
<CardHeader className="items-center text-center">
|
||||
<Logo />
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
{t('login.prompt', 'Enter your account name to continue')}
|
||||
</p>
|
||||
<Input
|
||||
id="username"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
autoFocus
|
||||
placeholder={t('login.usernamePlaceholder', 'user@example.com')}
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
disabled={loading}
|
||||
aria-label={t('login.prompt', 'Enter your account name to continue')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button type="submit" className="w-full" disabled={loading || !username.trim()}>
|
||||
{loading ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
{t('login.continue', 'Continue')}
|
||||
<ArrowRight />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export default function NotFound() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold">404</h1>
|
||||
<p className="mt-2 text-muted-foreground">{t('errors.notFound')}</p>
|
||||
<Link to="/" className="mt-4 inline-block text-primary underline hover:no-underline">
|
||||
{t('common.back')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { getBasePath } from '@/lib/basePath';
|
||||
import { exchangeCode, getStoredOAuthData, clearStoredOAuthData, getOAuthRedirectUri } from '@/services/auth/oauth';
|
||||
|
||||
export default function OAuthCallback() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function handleCallback() {
|
||||
try {
|
||||
const code = searchParams.get('code');
|
||||
const stateParam = searchParams.get('state');
|
||||
const errorParam = searchParams.get('error');
|
||||
const errorDescription = searchParams.get('error_description');
|
||||
|
||||
if (errorParam) {
|
||||
throw new Error(errorDescription || errorParam);
|
||||
}
|
||||
|
||||
if (!code || !stateParam) {
|
||||
throw new Error(t('oauth.missingParams', 'Missing authorization code or state parameter'));
|
||||
}
|
||||
|
||||
const { codeVerifier, tokenEndpoint, state, returnUrl } = getStoredOAuthData();
|
||||
|
||||
if (!state || state !== stateParam) {
|
||||
throw new Error(t('oauth.stateMismatch', 'State parameter mismatch. Please try logging in again.'));
|
||||
}
|
||||
|
||||
if (!codeVerifier || !tokenEndpoint) {
|
||||
throw new Error(t('oauth.missingData', 'Missing OAuth session data. Please try logging in again.'));
|
||||
}
|
||||
|
||||
const redirectUri = getOAuthRedirectUri();
|
||||
const tokenResponse = await exchangeCode(code, codeVerifier, tokenEndpoint, redirectUri);
|
||||
|
||||
useAuthStore
|
||||
.getState()
|
||||
.setTokens(tokenResponse.access_token, tokenResponse.refresh_token, tokenResponse.expires_in, tokenEndpoint);
|
||||
|
||||
clearStoredOAuthData();
|
||||
|
||||
const basePath = getBasePath();
|
||||
const stripBase = (p: string) => (basePath && p.startsWith(basePath) ? p.slice(basePath.length) || '/' : p);
|
||||
const candidate = returnUrl ? stripBase(returnUrl) : '/';
|
||||
const isAuthPath =
|
||||
candidate === '/login' ||
|
||||
candidate.startsWith('/login?') ||
|
||||
candidate === '/oauth/callback' ||
|
||||
candidate.startsWith('/oauth/callback?');
|
||||
const destination = isAuthPath ? '/' : candidate;
|
||||
navigate(destination, { replace: true });
|
||||
} catch (err) {
|
||||
clearStoredOAuthData();
|
||||
setError(err instanceof Error ? err.message : t('oauth.error', 'Authentication failed'));
|
||||
}
|
||||
}
|
||||
|
||||
handleCallback();
|
||||
}, [navigate, searchParams, t]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background px-4">
|
||||
<div className="w-full max-w-sm space-y-4 text-center">
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
<a href={`${getBasePath()}/login`} className="text-sm text-primary underline-offset-4 hover:underline">
|
||||
{t('oauth.backToLogin', 'Back to login')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-background">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">{t('oauth.processing', 'Completing sign in...')}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user