mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 11:47:11 +00:00
feat: add routable browser URLs for stacks and shell views (#1586)
* feat: add routable browser URLs for stacks and shell views Sync in-memory navigation to the address bar via a History API hook so deep links, refresh, Back/Forward, and bookmarks work across nodes, views, stack editor tabs, and mobile surfaces. Gate role/tier URL normalization on permissions and license readiness, preserve URLs on metadata fetch failure, and surface retryable stack-list errors without rewriting pending stack paths. * fix: preserve deep-link views on cold load and refresh Stop the node-switch effect from resetting to dashboard on initial mount. Defer URL writer settlement until hydrated activeView matches the route. Adds E2E coverage for shell cold loads, stack refresh, and compose env tab. * fix: keep mobile dashboard on list surface so sidebar renders On mobile, the URL sync hook was routing /nodes/<slug>/dashboard to the content surface, hiding the stack list sidebar. This prevented the data-stacks-loaded sentinel from appearing, causing sidebar truncation E2E tests to time out after reload on a mobile viewport. Mobile dashboard now stays on the list surface; other non-editor views still render on the content surface. * fix: complete mobile URL routing follow-ups for stack deep links Restore mobile /dashboard vs /stacks, list surface always writes /stacks. Hydrate pendingDetailStack, freeze compose failures with routeDetailError, and add unit plus E2E coverage. * fix: hydrate shell views from URL and sync in-app navigation Bootstrap activeView and tab state from the pathname on cold load. Settle route phase when state already matches, normalize unknown segments, and open Monaco editor tabs from stack deep links via applyEditorRouteState. * fix: prevent mobile stack deep links from hanging on cold load The resolvePendingStack effect did not re-fire when the pending stack ref was populated during URL hydration, because the urlHydratingStack state set in the same callback was not listed in the effect's dependency array. Adding it causes the effect to retry once hydration has committed. A resolvingRef mutex prevents concurrent invocations. When the target file is already loaded, route state is applied directly without calling loadFileForRoute, which avoids unmounting the editor (and hiding the recovery chip) if a background refresh triggers route resolution during a deploy operation. * test: adapt stack, deploy, and sidebar e2e specs to routable stack URLs * ci: raise E2E Playwright job timeout to 20 minutes
This commit is contained in:
@@ -4,6 +4,8 @@ type AppStatus = 'loading' | 'needsSetup' | 'notAuthenticated' | 'mfaChallenge'
|
||||
|
||||
export type UserRole = 'admin' | 'viewer' | 'deployer' | 'node-admin' | 'auditor';
|
||||
|
||||
export type PermissionsStatus = 'loading' | 'ready' | 'error';
|
||||
|
||||
export type PermissionAction =
|
||||
| 'stack:read' | 'stack:edit' | 'stack:deploy' | 'stack:create' | 'stack:delete'
|
||||
| 'node:read' | 'node:manage'
|
||||
@@ -28,6 +30,8 @@ interface AuthContextType {
|
||||
user: UserInfo | null;
|
||||
isAdmin: boolean;
|
||||
permissions: PermissionsData | null;
|
||||
permissionsStatus: PermissionsStatus;
|
||||
permissionsReady: boolean;
|
||||
can: (action: PermissionAction, resourceType?: string, resourceId?: string) => boolean;
|
||||
login: (username: string, password: string) => Promise<{ success: boolean; error?: string; mfaRequired?: boolean }>;
|
||||
ssoLdapLogin: (username: string, password: string) => Promise<{ success: boolean; error?: string; mfaRequired?: boolean }>;
|
||||
@@ -44,10 +48,16 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [appStatus, setAppStatus] = useState<AppStatus>('loading');
|
||||
const [user, setUser] = useState<UserInfo | null>(null);
|
||||
const [permissions, setPermissions] = useState<PermissionsData | null>(null);
|
||||
const [permissionsStatus, setPermissionsStatus] = useState<PermissionsStatus>('loading');
|
||||
|
||||
const resetPermissions = useCallback(() => {
|
||||
setPermissions(null);
|
||||
setPermissionsStatus('loading');
|
||||
}, []);
|
||||
|
||||
const checkAuth = async () => {
|
||||
resetPermissions();
|
||||
try {
|
||||
// First check if setup is needed
|
||||
const statusResponse = await fetch('/api/auth/status', {
|
||||
credentials: 'include',
|
||||
});
|
||||
@@ -56,28 +66,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
if (statusData.needsSetup) {
|
||||
setAppStatus('needsSetup');
|
||||
setUser(null);
|
||||
setPermissions(null);
|
||||
resetPermissions();
|
||||
return;
|
||||
}
|
||||
|
||||
// If a partial-auth (mfa_pending) cookie is active, route to the
|
||||
// challenge screen. This handles reloads in the middle of the flow,
|
||||
// including post-OIDC redirects.
|
||||
if (statusData.mfaPending) {
|
||||
setUser(null);
|
||||
setPermissions(null);
|
||||
resetPermissions();
|
||||
setAppStatus('mfaChallenge');
|
||||
return;
|
||||
}
|
||||
|
||||
// Auth check and permissions fetch are independent for an authenticated
|
||||
// session, so fire both on the wire at the same time. Await only the
|
||||
// auth check before committing app state — otherwise a slow
|
||||
// /permissions/me delays setAppStatus('authenticated') and races
|
||||
// post-reload UI that expects the dashboard to commit promptly. The
|
||||
// permissions promise updates state in the background when it resolves.
|
||||
const authPromise = fetch('/api/auth/check', { credentials: 'include' });
|
||||
const permsPromise = fetch('/api/permissions/me', { credentials: 'include' }).catch(() => null);
|
||||
const permsPromise = fetch('/api/permissions/me', { credentials: 'include' });
|
||||
|
||||
const authResponse = await authPromise;
|
||||
if (authResponse.ok) {
|
||||
@@ -85,30 +86,36 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setUser(data.user ?? null);
|
||||
setAppStatus('authenticated');
|
||||
|
||||
void permsPromise.then(async (res) => {
|
||||
if (res?.ok) {
|
||||
try {
|
||||
setPermissions(await res.json());
|
||||
} catch {
|
||||
// Permissions fetch is non-critical — fallback to global role only
|
||||
}
|
||||
try {
|
||||
const res = await permsPromise;
|
||||
if (res.ok) {
|
||||
setPermissions(await res.json());
|
||||
setPermissionsStatus('ready');
|
||||
} else {
|
||||
setPermissionsStatus('error');
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
setPermissionsStatus('error');
|
||||
}
|
||||
} else {
|
||||
setUser(null);
|
||||
setPermissions(null);
|
||||
resetPermissions();
|
||||
setAppStatus('notAuthenticated');
|
||||
}
|
||||
} catch {
|
||||
setUser(null);
|
||||
setPermissions(null);
|
||||
resetPermissions();
|
||||
setAppStatus('notAuthenticated');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
const handleUnauthorized = () => setAppStatus('notAuthenticated');
|
||||
const handleUnauthorized = () => {
|
||||
setUser(null);
|
||||
resetPermissions();
|
||||
setAppStatus('notAuthenticated');
|
||||
};
|
||||
window.addEventListener('sencho-unauthorized', handleUnauthorized);
|
||||
return () => window.removeEventListener('sencho-unauthorized', handleUnauthorized);
|
||||
}, []);
|
||||
@@ -116,13 +123,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const can = useCallback((action: PermissionAction, resourceType?: string, resourceId?: string): boolean => {
|
||||
if (!permissions) return false;
|
||||
|
||||
// Admins always have full access
|
||||
if (permissions.globalRole === 'admin') return true;
|
||||
|
||||
// Check global role permissions
|
||||
if (permissions.globalPermissions.includes(action)) return true;
|
||||
|
||||
// Check scoped permissions
|
||||
if (resourceType && resourceId) {
|
||||
const key = `${resourceType}:${resourceId}`;
|
||||
return permissions.scopedPermissions[key]?.includes(action) ?? false;
|
||||
@@ -146,13 +150,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
if (response.ok && data.success) {
|
||||
if (data.mfaRequired) {
|
||||
// Password was accepted but a second factor is required. Pull the
|
||||
// updated /auth/status so the app routes to the challenge screen.
|
||||
await checkAuth();
|
||||
return { success: true, mfaRequired: true };
|
||||
}
|
||||
setAppStatus('authenticated');
|
||||
// Fetch user info (role, username) so isAdmin is correct immediately
|
||||
await checkAuth();
|
||||
return { success: true };
|
||||
} else {
|
||||
@@ -220,7 +221,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
console.error('Cancel MFA error:', error);
|
||||
} finally {
|
||||
setUser(null);
|
||||
setPermissions(null);
|
||||
resetPermissions();
|
||||
setAppStatus('notAuthenticated');
|
||||
}
|
||||
};
|
||||
@@ -235,13 +236,12 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
console.error('Logout error:', error);
|
||||
} finally {
|
||||
setUser(null);
|
||||
setPermissions(null);
|
||||
resetPermissions();
|
||||
setAppStatus('notAuthenticated');
|
||||
}
|
||||
};
|
||||
|
||||
const completeSetup = () => {
|
||||
// Fetch user info so isAdmin is correct after setup
|
||||
checkAuth();
|
||||
};
|
||||
|
||||
@@ -253,6 +253,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
user,
|
||||
isAdmin: user?.role === 'admin',
|
||||
permissions,
|
||||
permissionsStatus,
|
||||
permissionsReady: permissionsStatus !== 'loading',
|
||||
can,
|
||||
login,
|
||||
ssoLdapLogin,
|
||||
|
||||
@@ -4,6 +4,8 @@ import { apiFetch } from '@/lib/api';
|
||||
export type LicenseTier = 'community' | 'paid';
|
||||
export type LicenseStatus = 'community' | 'trial' | 'active' | 'expired' | 'disabled';
|
||||
|
||||
export type LicenseFetchStatus = 'loading' | 'ready' | 'error';
|
||||
|
||||
export interface LicenseInfo {
|
||||
tier: LicenseTier;
|
||||
status: LicenseStatus;
|
||||
@@ -21,6 +23,8 @@ interface LicenseContextType {
|
||||
license: LicenseInfo | null;
|
||||
isPaid: boolean;
|
||||
loading: boolean;
|
||||
licenseStatus: LicenseFetchStatus;
|
||||
licenseReady: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
activate: (licenseKey: string) => Promise<{ success: boolean; error?: string }>;
|
||||
deactivate: () => Promise<{ success: boolean; error?: string }>;
|
||||
@@ -31,16 +35,22 @@ const LicenseContext = createContext<LicenseContextType | undefined>(undefined);
|
||||
export function LicenseProvider({ children }: { children: ReactNode }) {
|
||||
const [license, setLicense] = useState<LicenseInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [licenseStatus, setLicenseStatus] = useState<LicenseFetchStatus>('loading');
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setLicenseStatus('loading');
|
||||
try {
|
||||
const res = await apiFetch('/license', { localOnly: true });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setLicense(data);
|
||||
setLicenseStatus('ready');
|
||||
} else {
|
||||
setLicenseStatus('error');
|
||||
}
|
||||
} catch {
|
||||
// Silently fail - license info is non-critical
|
||||
setLicenseStatus('error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -60,6 +70,7 @@ export function LicenseProvider({ children }: { children: ReactNode }) {
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
setLicense(data.license);
|
||||
setLicenseStatus('ready');
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: data.error || 'Activation failed' };
|
||||
@@ -77,6 +88,7 @@ export function LicenseProvider({ children }: { children: ReactNode }) {
|
||||
const data = await res.json();
|
||||
if (res.ok && data.success) {
|
||||
setLicense(data.license);
|
||||
setLicenseStatus('ready');
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: data.error || 'Deactivation failed' };
|
||||
@@ -88,7 +100,16 @@ export function LicenseProvider({ children }: { children: ReactNode }) {
|
||||
const isPaid = license?.tier === 'paid';
|
||||
|
||||
return (
|
||||
<LicenseContext.Provider value={{ license, isPaid, loading, refresh, activate, deactivate }}>
|
||||
<LicenseContext.Provider value={{
|
||||
license,
|
||||
isPaid,
|
||||
loading,
|
||||
licenseStatus,
|
||||
licenseReady: licenseStatus !== 'loading',
|
||||
refresh,
|
||||
activate,
|
||||
deactivate,
|
||||
}}>
|
||||
{children}
|
||||
</LicenseContext.Provider>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user