mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-04 04:55:41 +02:00
Compare commits
1 Commits
agent/lamb
...
refactor/d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f85494dcf8 |
@@ -48,6 +48,19 @@ function handleDeepLink(url: string): void {
|
||||
if (token && mainWindow) {
|
||||
mainWindow.webContents.send("auth:token", token);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// multica://invite/<invitationId>
|
||||
// Dispatched from the web invite page when the user chooses "Open in
|
||||
// desktop app". The renderer opens the invite overlay — no tab, no
|
||||
// route persistence, so deep-linking the same invite twice stays safe.
|
||||
if (parsed.hostname === "invite") {
|
||||
const id = parsed.pathname.replace(/^\//, "");
|
||||
if (id && mainWindow) {
|
||||
mainWindow.webContents.send("invite:open", decodeURIComponent(id));
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed URLs
|
||||
|
||||
2
apps/desktop/src/preload/index.d.ts
vendored
2
apps/desktop/src/preload/index.d.ts
vendored
@@ -3,6 +3,8 @@ import { ElectronAPI } from "@electron-toolkit/preload";
|
||||
interface DesktopAPI {
|
||||
/** Listen for auth token delivered via deep link. Returns an unsubscribe function. */
|
||||
onAuthToken: (callback: (token: string) => void) => () => void;
|
||||
/** Listen for invitation IDs delivered via deep link. Returns an unsubscribe function. */
|
||||
onInviteOpen: (callback: (invitationId: string) => void) => () => void;
|
||||
/** Open a URL in the default browser. */
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
/** Hide macOS traffic lights for full-screen modals; restore when false. */
|
||||
|
||||
@@ -11,6 +11,15 @@ const desktopAPI = {
|
||||
ipcRenderer.removeListener("auth:token", handler);
|
||||
};
|
||||
},
|
||||
/** Listen for invitation IDs delivered via deep link */
|
||||
onInviteOpen: (callback: (invitationId: string) => void) => {
|
||||
const handler = (_event: Electron.IpcRendererEvent, invitationId: string) =>
|
||||
callback(invitationId);
|
||||
ipcRenderer.on("invite:open", handler);
|
||||
return () => {
|
||||
ipcRenderer.removeListener("invite:open", handler);
|
||||
};
|
||||
},
|
||||
/** Open a URL in the default browser */
|
||||
openExternal: (url: string) => ipcRenderer.invoke("shell:openExternal", url),
|
||||
/** Toggle immersive mode — hide macOS traffic lights for full-screen modals */
|
||||
|
||||
@@ -11,6 +11,7 @@ import { DesktopLoginPage } from "./pages/login";
|
||||
import { DesktopShell } from "./components/desktop-layout";
|
||||
import { UpdateNotification } from "./components/update-notification";
|
||||
import { useTabStore } from "./stores/tab-store";
|
||||
import { useWindowOverlayStore } from "./stores/window-overlay-store";
|
||||
|
||||
function AppContent() {
|
||||
const user = useAuthStore((s) => s.user);
|
||||
@@ -31,6 +32,17 @@ function AppContent() {
|
||||
window.daemonAPI.setTargetApiUrl(DAEMON_TARGET_API_URL);
|
||||
}, []);
|
||||
|
||||
// Listen for invite IDs delivered via deep link (multica://invite/<id>).
|
||||
// We open the overlay regardless of login state — if the user isn't logged
|
||||
// in, InvitePage's queries will fail and render the "not found" state,
|
||||
// which is acceptable; the expected pre-flight happens in the web app
|
||||
// (login + next=/invite/... dance) before the deep link is ever dispatched.
|
||||
useEffect(() => {
|
||||
return window.desktopAPI.onInviteOpen((invitationId) => {
|
||||
useWindowOverlayStore.getState().open({ type: "invite", invitationId });
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Listen for auth token delivered via deep link (multica://auth/callback?token=...).
|
||||
// daemonAPI.syncToken is handled separately by the [user] effect below, which
|
||||
// fires whenever a user logs in (deep link, session restore, account switch).
|
||||
@@ -135,9 +147,14 @@ function AppContent() {
|
||||
const DAEMON_TARGET_API_URL =
|
||||
import.meta.env.VITE_API_URL || "http://localhost:8080";
|
||||
|
||||
// On logout, clear any cached PAT and stop the daemon so that a subsequent
|
||||
// login as a different user never inherits the previous user's credentials.
|
||||
// On logout, wipe desktop-only in-memory state and stop the daemon so that
|
||||
// a subsequent login as a different user never inherits the previous user's
|
||||
// tabs, overlay, or credentials. Zustand persist only writes to localStorage;
|
||||
// useLogout clears the storage key, but the live stores stay populated until
|
||||
// we explicitly reset them here.
|
||||
async function handleDaemonLogout() {
|
||||
useTabStore.getState().reset();
|
||||
useWindowOverlayStore.getState().close();
|
||||
try {
|
||||
await window.daemonAPI.clearToken();
|
||||
} catch {
|
||||
|
||||
@@ -18,6 +18,7 @@ import { getCurrentSlug, subscribeToCurrentSlug } from "@multica/core/platform";
|
||||
import { DesktopNavigationProvider } from "@/platform/navigation";
|
||||
import { TabBar } from "./tab-bar";
|
||||
import { TabContent } from "./tab-content";
|
||||
import { WindowOverlay } from "./window-overlay";
|
||||
|
||||
function SidebarTopBar() {
|
||||
const { canGoBack, canGoForward, goBack, goForward } = useTabHistory();
|
||||
@@ -113,7 +114,8 @@ export function DesktopShell() {
|
||||
mount WorkspaceRouteLayout, which calls setCurrentWorkspace()
|
||||
to populate the slug. The sidebar gates on slug being present
|
||||
to avoid the useRequiredWorkspaceSlug throw. Zero-workspace
|
||||
users are routed to /workspaces/new by IndexRedirect. */}
|
||||
users see the window-level overlay (new-workspace flow)
|
||||
triggered by IndexRedirect, not a route. */}
|
||||
<WorkspaceSlugProvider slug={slug}>
|
||||
<div className="flex h-screen">
|
||||
<SidebarProvider className="flex-1">
|
||||
@@ -132,6 +134,7 @@ export function DesktopShell() {
|
||||
</div>
|
||||
{slug && <ModalRegistry />}
|
||||
{slug && <SearchCommand />}
|
||||
<WindowOverlay />
|
||||
</WorkspaceSlugProvider>
|
||||
</DesktopNavigationProvider>
|
||||
);
|
||||
|
||||
76
apps/desktop/src/renderer/src/components/window-overlay.tsx
Normal file
76
apps/desktop/src/renderer/src/components/window-overlay.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useImmersiveMode } from "@multica/views/platform";
|
||||
import { NewWorkspacePage } from "@multica/views/workspace/new-workspace-page";
|
||||
import { InvitePage } from "@multica/views/invite";
|
||||
import { useNavigation } from "@multica/views/navigation";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import { workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
import { useWindowOverlayStore } from "@/stores/window-overlay-store";
|
||||
|
||||
/**
|
||||
* Window-level transition overlay: renders above the tab system when the
|
||||
* user is in a pre-workspace flow (create workspace, accept invite).
|
||||
*
|
||||
* This component is a thin **platform shell**:
|
||||
* - Hands the window-drag strip and macOS traffic-light hiding
|
||||
* (`useImmersiveMode`) — both are platform-specific, web has neither
|
||||
* - Covers the tab system (fixed inset, z-50) so the Shell's own TabBar
|
||||
* doesn't leak through
|
||||
*
|
||||
* All UX affordances (Back button, Log out button, welcome copy, invite
|
||||
* card) live inside the shared `NewWorkspacePage` / `InvitePage`
|
||||
* components under `packages/views/`, so web and desktop render identical
|
||||
* content. The platform split is: UX in shared code, chrome here.
|
||||
*/
|
||||
export function WindowOverlay() {
|
||||
const overlay = useWindowOverlayStore((s) => s.overlay);
|
||||
if (!overlay) return null;
|
||||
return <WindowOverlayInner />;
|
||||
}
|
||||
|
||||
function WindowOverlayInner() {
|
||||
const overlay = useWindowOverlayStore((s) => s.overlay);
|
||||
const close = useWindowOverlayStore((s) => s.close);
|
||||
const { push } = useNavigation();
|
||||
const { data: wsList = [] } = useQuery(workspaceListOptions());
|
||||
|
||||
useImmersiveMode();
|
||||
|
||||
if (!overlay) return null;
|
||||
|
||||
// Back is only meaningful when there's somewhere to go — i.e. the user
|
||||
// has at least one workspace. Zero-workspace users can only Log out or
|
||||
// complete the flow.
|
||||
const onBack = wsList.length > 0 ? close : undefined;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col bg-background">
|
||||
{/* Window-drag strip — OS-level. Transparent; covers the area
|
||||
traffic lights would normally sit (they're hidden by
|
||||
useImmersiveMode on macOS). */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-x-0 top-0 h-10 z-10"
|
||||
style={{ WebkitAppRegion: "drag" } as React.CSSProperties}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="flex-1 min-h-0 overflow-auto"
|
||||
style={{ WebkitAppRegion: "no-drag" } as React.CSSProperties}
|
||||
>
|
||||
{overlay.type === "new-workspace" && (
|
||||
<NewWorkspacePage
|
||||
onSuccess={(ws) => push(paths.workspace(ws.slug).issues())}
|
||||
onBack={onBack}
|
||||
/>
|
||||
)}
|
||||
{overlay.type === "invite" && (
|
||||
<InvitePage
|
||||
invitationId={overlay.invitationId}
|
||||
onBack={onBack}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import { WorkspaceSlugProvider, paths } from "@multica/core/paths";
|
||||
import { workspaceBySlugOptions } from "@multica/core/workspace";
|
||||
import { setCurrentWorkspace } from "@multica/core/platform";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { NoAccessPage } from "@multica/views/workspace/no-access-page";
|
||||
import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen";
|
||||
|
||||
/**
|
||||
@@ -17,9 +16,13 @@ import { useWorkspaceSeen } from "@multica/views/workspace/use-workspace-seen";
|
||||
* guaranteed non-null when called. Two industry-standard identities are
|
||||
* kept distinct: slug (URL / browser) and UUID (API / cache keys).
|
||||
*
|
||||
* If the slug doesn't resolve to any workspace the user has access to,
|
||||
* we render NoAccessPage instead of silently redirecting — users get
|
||||
* explicit feedback for stale bookmarks or revoked access.
|
||||
* Unlike web, desktop never renders a "workspace not available" page: the
|
||||
* app has no URL bar and no clickable links from outside the session, so
|
||||
* landing on an inaccessible slug can only mean stale state (persisted tab
|
||||
* from a previous account) or active eviction (admin removal, realtime
|
||||
* delete). Both cases resolve by bouncing to `/`, where IndexRedirect
|
||||
* picks a valid destination — the next workspace, or the new-workspace
|
||||
* overlay if the user has none.
|
||||
*/
|
||||
export function WorkspaceRouteLayout() {
|
||||
const { workspaceSlug } = useParams<{ workspaceSlug: string }>();
|
||||
@@ -49,11 +52,22 @@ export function WorkspaceRouteLayout() {
|
||||
setCurrentWorkspace(workspaceSlug, workspace.id);
|
||||
}
|
||||
|
||||
// Remember whether this slug has resolved before (see hook docs). Gates
|
||||
// the NoAccessPage render below so active workspace removal doesn't
|
||||
// flash "Workspace not available" before the navigate lands.
|
||||
// Remember whether this slug has resolved before. `useWorkspaceSeen`
|
||||
// gates the auto-heal below so the mid-flight frame of an active-removal
|
||||
// navigation doesn't double-bounce.
|
||||
const hasBeenSeen = useWorkspaceSeen(workspaceSlug, !!workspace);
|
||||
|
||||
// Stale slug (tab persisted from a previous account, or revoked access
|
||||
// that hasn't yet been cleaned up by validateWorkspaceSlugs): auto-heal
|
||||
// to `/`. IndexRedirect takes it from there.
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
if (!listFetched) return;
|
||||
if (workspace) return;
|
||||
if (hasBeenSeen) return; // active eviction in flight — let the other path win
|
||||
navigate("/", { replace: true });
|
||||
}, [user, listFetched, workspace, hasBeenSeen, navigate]);
|
||||
|
||||
if (isAuthLoading) return null;
|
||||
if (!workspaceSlug) return null;
|
||||
// Don't render children until workspace is resolved. useWorkspaceId()
|
||||
@@ -61,15 +75,7 @@ export function WorkspaceRouteLayout() {
|
||||
// unknown — gating here is the single point where that invariant is
|
||||
// enforced, so every descendant can call useWorkspaceId() safely.
|
||||
if (!listFetched) return null;
|
||||
if (!workspace) {
|
||||
// Active workspace just removed (delete/leave/realtime eviction) —
|
||||
// navigate is in flight; hold null briefly instead of flashing
|
||||
// NoAccessPage.
|
||||
if (hasBeenSeen) return null;
|
||||
// Genuinely inaccessible slug (stale bookmark, revoked access, or a
|
||||
// link from a former teammate's workspace) → explicit feedback.
|
||||
return <NoAccessPage />;
|
||||
}
|
||||
if (!workspace) return null; // auto-heal effect above handles the navigation
|
||||
|
||||
return (
|
||||
<WorkspaceSlugProvider slug={workspaceSlug}>
|
||||
|
||||
@@ -6,15 +6,67 @@ import {
|
||||
} from "@multica/views/navigation";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { useTabStore, resolveRouteIcon } from "@/stores/tab-store";
|
||||
import { useWindowOverlayStore } from "@/stores/window-overlay-store";
|
||||
|
||||
const ROOT_PATH = "/";
|
||||
|
||||
// Public web app URL — injected at build time via .env.production. Falls
|
||||
// back to the production host for dev builds so "Copy link" yields a URL
|
||||
// that actually points somewhere a teammate can open.
|
||||
const APP_URL = import.meta.env.VITE_APP_URL || "https://multica.ai";
|
||||
|
||||
/**
|
||||
* Intercept navigation to "transition" paths — pre-workspace flows that on
|
||||
* desktop are rendered as a window-level overlay instead of a tab route.
|
||||
* Returns `true` if the navigation was handled (caller should NOT proceed
|
||||
* with its own router navigation).
|
||||
*
|
||||
* Side effect: when opening the new-workspace overlay, the tab router is
|
||||
* ALSO reset to "/". Rationale — the only way a push lands on
|
||||
* /workspaces/new is that the workspace context is gone (fresh install,
|
||||
* delete-last, leave-last). Leaving the tab parked on a workspace-scoped
|
||||
* path like /acme/settings keeps those components mounted under the
|
||||
* overlay; the next render after the list cache updates would then throw
|
||||
* (useWorkspaceId, useCurrentWorkspace, etc) because the slug no longer
|
||||
* resolves. Resetting to "/" unmounts them synchronously. For invite
|
||||
* overlay we intentionally do NOT reset — invite is orthogonal and the
|
||||
* user's workspace context may still be valid.
|
||||
*
|
||||
* Navigations to any other path implicitly close an open overlay, so the
|
||||
* overlay's lifetime is tied to the user staying in a transition state.
|
||||
*/
|
||||
function tryRouteToOverlay(path: string, router?: DataRouter): boolean {
|
||||
const overlay = useWindowOverlayStore.getState();
|
||||
if (path === "/workspaces/new") {
|
||||
overlay.open({ type: "new-workspace" });
|
||||
if (router && router.state.location.pathname !== ROOT_PATH) {
|
||||
router.navigate(ROOT_PATH, { replace: true });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (path.startsWith("/invite/")) {
|
||||
let id = "";
|
||||
try {
|
||||
id = decodeURIComponent(path.slice("/invite/".length));
|
||||
} catch {
|
||||
// Malformed percent-encoding — treat as no-op so the caller's push
|
||||
// doesn't attempt to navigate into the (now-removed) invite route
|
||||
// and leave the tab stuck on a 404-shaped path.
|
||||
return true;
|
||||
}
|
||||
if (id) {
|
||||
overlay.open({ type: "invite", invitationId: id });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Any other navigation cancels a live overlay.
|
||||
if (overlay.overlay) overlay.close();
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Root-level navigation provider for components outside the per-tab RouterProviders
|
||||
* (sidebar, search dialog, modals, etc.).
|
||||
* (sidebar, search dialog, modals, WindowOverlay contents, etc.).
|
||||
*
|
||||
* Reads from the active tab's memory router via router.subscribe().
|
||||
* Does NOT use any react-router hooks — it's above all RouterProviders.
|
||||
@@ -25,7 +77,7 @@ export function DesktopNavigationProvider({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const activeTab = useTabStore((s) => s.tabs.find((t) => t.id === s.activeTabId));
|
||||
const [pathname, setPathname] = useState(activeTab?.path ?? "/issues");
|
||||
const [pathname, setPathname] = useState(activeTab?.path ?? "/");
|
||||
|
||||
// Subscribe to the active tab's router for pathname updates
|
||||
useEffect(() => {
|
||||
@@ -47,12 +99,14 @@ export function DesktopNavigationProvider({
|
||||
const tab = useTabStore.getState().tabs.find(
|
||||
(t) => t.id === useTabStore.getState().activeTabId,
|
||||
);
|
||||
if (tryRouteToOverlay(path, tab?.router)) return;
|
||||
tab?.router.navigate(path);
|
||||
},
|
||||
replace: (path: string) => {
|
||||
const tab = useTabStore.getState().tabs.find(
|
||||
(t) => t.id === useTabStore.getState().activeTabId,
|
||||
);
|
||||
if (tryRouteToOverlay(path, tab?.router)) return;
|
||||
tab?.router.navigate(path, { replace: true });
|
||||
},
|
||||
back: () => {
|
||||
@@ -101,8 +155,14 @@ export function TabNavigationProvider({
|
||||
|
||||
const adapter: NavigationAdapter = useMemo(
|
||||
() => ({
|
||||
push: (path: string) => router.navigate(path),
|
||||
replace: (path: string) => router.navigate(path, { replace: true }),
|
||||
push: (path: string) => {
|
||||
if (tryRouteToOverlay(path, router)) return;
|
||||
router.navigate(path);
|
||||
},
|
||||
replace: (path: string) => {
|
||||
if (tryRouteToOverlay(path, router)) return;
|
||||
router.navigate(path, { replace: true });
|
||||
},
|
||||
back: () => router.navigate(-1),
|
||||
pathname: location.pathname,
|
||||
searchParams: new URLSearchParams(location.search),
|
||||
|
||||
@@ -20,14 +20,12 @@ import { DaemonRuntimeCard } from "./components/daemon-runtime-card";
|
||||
import { AgentsPage } from "@multica/views/agents";
|
||||
import { InboxPage } from "@multica/views/inbox";
|
||||
import { SettingsPage } from "@multica/views/settings";
|
||||
import { NewWorkspacePage } from "@multica/views/workspace/new-workspace-page";
|
||||
import { InvitePage } from "@multica/views/invite";
|
||||
import { useNavigation } from "@multica/views/navigation";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import { workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
import { Server } from "lucide-react";
|
||||
import { DaemonSettingsTab } from "./components/daemon-settings-tab";
|
||||
import { WorkspaceRouteLayout } from "./components/workspace-route-layout";
|
||||
import { useWindowOverlayStore } from "./stores/window-overlay-store";
|
||||
|
||||
/**
|
||||
* Sets document.title from the deepest matched route's handle.title.
|
||||
@@ -59,15 +57,6 @@ function PageShell() {
|
||||
);
|
||||
}
|
||||
|
||||
function NewWorkspaceRoute() {
|
||||
const nav = useNavigation();
|
||||
return (
|
||||
<NewWorkspacePage
|
||||
onSuccess={(ws) => nav.push(paths.workspace(ws.slug).issues())}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Root index route: resolves the URL-less `/` path to a concrete destination.
|
||||
*
|
||||
@@ -76,15 +65,32 @@ function NewWorkspaceRoute() {
|
||||
* duplicate fetches across tabs — each tab's memory router hits this
|
||||
* component independently but the query is deduped.
|
||||
*
|
||||
* Sends first-time users without any workspace to /workspaces/new,
|
||||
* everyone else to their first workspace's issues page. Persisted tab
|
||||
* paths that already carry a workspace slug bypass this component
|
||||
* entirely.
|
||||
* Sends users with workspaces to the first workspace's issues page.
|
||||
* Users with zero workspaces get the window-level new-workspace overlay —
|
||||
* desktop treats pre-workspace flows as application state, not tab routes,
|
||||
* so there's no URL to navigate to.
|
||||
*/
|
||||
function IndexRedirect() {
|
||||
const { data: wsList, isFetched } = useQuery(workspaceListOptions());
|
||||
|
||||
// Wait for the query to settle so we don't redirect to /workspaces/new
|
||||
// Bidirectional overlay lifecycle: open the new-workspace overlay when
|
||||
// the user has zero workspaces AND no other overlay is already showing,
|
||||
// and close it when the list becomes non-empty (e.g. a realtime workspace
|
||||
// event arrived while the overlay was open on a different code path).
|
||||
// Only touches the new-workspace type — an active invite overlay is the
|
||||
// user's in-flight task and must not be interrupted.
|
||||
useEffect(() => {
|
||||
if (!isFetched) return;
|
||||
const { overlay, open, close } = useWindowOverlayStore.getState();
|
||||
const isEmpty = !wsList || wsList.length === 0;
|
||||
if (isEmpty) {
|
||||
if (!overlay) open({ type: "new-workspace" });
|
||||
} else if (overlay?.type === "new-workspace") {
|
||||
close();
|
||||
}
|
||||
}, [isFetched, wsList]);
|
||||
|
||||
// Wait for the query to settle so we don't flash the empty-state overlay
|
||||
// on the initial render before the seeded/fetched data arrives.
|
||||
if (!isFetched) return null;
|
||||
|
||||
@@ -92,24 +98,21 @@ function IndexRedirect() {
|
||||
if (firstWorkspace) {
|
||||
return <Navigate to={paths.workspace(firstWorkspace.slug).issues()} replace />;
|
||||
}
|
||||
return <Navigate to={paths.newWorkspace()} replace />;
|
||||
}
|
||||
|
||||
function InviteRoute() {
|
||||
const matches = useMatches();
|
||||
const match = matches.find((m) => (m.params as { id?: string }).id);
|
||||
const id = (match?.params as { id?: string })?.id ?? "";
|
||||
return <InvitePage invitationId={id} />;
|
||||
// Zero workspaces — overlay is opened via the effect above. Tab stays on
|
||||
// `/`; the overlay covers the window. When the user creates a workspace,
|
||||
// onSuccess navigates to the new workspace path, closing the overlay.
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route definitions shared by all tabs.
|
||||
*
|
||||
* Structure mirrors the web app's [workspaceSlug]/... layout: all dashboard
|
||||
* pages live under /:workspaceSlug, with WorkspaceRouteLayout resolving the
|
||||
* slug to a workspace and syncing side-effects (api client, persist namespace,
|
||||
* Zustand mirror). Global (pre-workspace) routes — workspaces/new and invite —
|
||||
* sit at the top level alongside the workspace wrapper.
|
||||
* Only workspace-scoped ("session") routes live here. Pre-workspace
|
||||
* transitions (create workspace, accept invite) are NOT routes on desktop —
|
||||
* they render as a window-level overlay via WindowOverlay, dispatched by
|
||||
* the navigation adapter's transition-path interception. See
|
||||
* `platform/navigation.tsx` and `stores/window-overlay-store.ts`.
|
||||
*/
|
||||
export const appRoutes: RouteObject[] = [
|
||||
{
|
||||
@@ -118,18 +121,9 @@ export const appRoutes: RouteObject[] = [
|
||||
// Top-level index: no slug yet. `IndexRedirect` reads the workspace
|
||||
// list from React Query cache (seeded by AuthInitializer on reopen
|
||||
// or App.tsx on deep-link login) and bounces to the first
|
||||
// workspace's issues page — or /workspaces/new if the user has none.
|
||||
// workspace's issues page — or opens the new-workspace overlay if
|
||||
// the user has none.
|
||||
{ index: true, element: <IndexRedirect /> },
|
||||
{
|
||||
path: "workspaces/new",
|
||||
element: <NewWorkspaceRoute />,
|
||||
handle: { title: "Create Workspace" },
|
||||
},
|
||||
{
|
||||
path: "invite/:id",
|
||||
element: <InviteRoute />,
|
||||
handle: { title: "Accept Invite" },
|
||||
},
|
||||
{
|
||||
path: ":workspaceSlug",
|
||||
element: <WorkspaceRouteLayout />,
|
||||
|
||||
@@ -13,11 +13,14 @@ describe("sanitizeTabPath", () => {
|
||||
expect(sanitizeTabPath("/")).toBe("/");
|
||||
});
|
||||
|
||||
it("passes through global paths", () => {
|
||||
expect(sanitizeTabPath("/login")).toBe("/login");
|
||||
expect(sanitizeTabPath("/workspaces/new")).toBe("/workspaces/new");
|
||||
expect(sanitizeTabPath("/invite/abc")).toBe("/invite/abc");
|
||||
expect(sanitizeTabPath("/auth/callback")).toBe("/auth/callback");
|
||||
it("rejects transition paths — these are overlay state, not tabs", () => {
|
||||
// Silently rewritten (no warn) since they're legitimate inputs that
|
||||
// the navigation adapter has already redirected to the overlay.
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
expect(sanitizeTabPath("/workspaces/new")).toBe("/");
|
||||
expect(sanitizeTabPath("/invite/abc")).toBe("/");
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it("passes through valid workspace-scoped paths", () => {
|
||||
@@ -25,7 +28,7 @@ describe("sanitizeTabPath", () => {
|
||||
expect(sanitizeTabPath("/my-team/projects/abc")).toBe("/my-team/projects/abc");
|
||||
});
|
||||
|
||||
it("rejects paths whose first segment is a reserved slug", () => {
|
||||
it("rejects paths whose first segment is a reserved slug (missing workspace prefix)", () => {
|
||||
// A stray "/issues" (pre-refactor leftover, missing workspace prefix)
|
||||
// would be interpreted as workspaceSlug="issues" → NoAccessPage.
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { arrayMove } from "@dnd-kit/sortable";
|
||||
import { createPersistStorage, defaultStorage } from "@multica/core/platform";
|
||||
import { createSafeId } from "@multica/core/utils";
|
||||
import { isGlobalPath, isReservedSlug } from "@multica/core/paths";
|
||||
import { isReservedSlug } from "@multica/core/paths";
|
||||
import type { DataRouter } from "react-router-dom";
|
||||
import { createTabRouter } from "../routes";
|
||||
|
||||
@@ -44,10 +44,17 @@ interface TabStore {
|
||||
* current user doesn't have access to. Called after login + workspace list
|
||||
* is populated (and on every subsequent list change, e.g. realtime
|
||||
* workspace:deleted). Stale tabs get reset to `/` so IndexRedirect picks
|
||||
* a valid workspace; tabs on global paths (/login, /workspaces/new, etc.)
|
||||
* are untouched.
|
||||
* a valid workspace (or opens the new-workspace overlay if the user now
|
||||
* has none).
|
||||
*/
|
||||
validateWorkspaceSlugs: (validSlugs: Set<string>) => void;
|
||||
/**
|
||||
* Wipe all in-memory tabs and reseed with a single default tab at `/`.
|
||||
* Called on logout so the next user doesn't inherit the previous user's
|
||||
* tab layout — Zustand persist only writes to localStorage; clearing
|
||||
* localStorage alone leaves the live store untouched until app restart.
|
||||
*/
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -67,21 +74,17 @@ const ROUTE_ICONS: Record<string, string> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a route icon from a pathname. Title is NOT determined here — it
|
||||
* comes from document.title.
|
||||
* Resolve a route icon from a pathname.
|
||||
*
|
||||
* Path shape after the workspace URL refactor:
|
||||
* - workspace-scoped: `/{workspaceSlug}/{route}/...` → use segment index 1
|
||||
* - global (workspaces/new, invite, auth, login): `/{route}/...` → use segment index 0
|
||||
* Tab paths are always workspace-scoped on desktop: `/{slug}/{route}/...`,
|
||||
* so the route segment lives at index 1. Pre-workspace flows (create,
|
||||
* invite) are rendered by the window overlay, never as tabs.
|
||||
*
|
||||
* `isGlobalPath` is the single source of truth for which prefixes are global.
|
||||
* Title is NOT determined here — it comes from document.title.
|
||||
*/
|
||||
export function resolveRouteIcon(pathname: string): string {
|
||||
const segments = pathname.split("/").filter(Boolean);
|
||||
const routeSegment = isGlobalPath(pathname)
|
||||
? (segments[0] ?? "")
|
||||
: (segments[1] ?? "");
|
||||
return ROUTE_ICONS[routeSegment] ?? "ListTodo";
|
||||
return ROUTE_ICONS[segments[1] ?? ""] ?? "ListTodo";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -105,30 +108,37 @@ function createId(): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Defensive: catch tab paths that were constructed without a workspace slug
|
||||
* (e.g. a hardcoded "/issues" leftover from before the URL refactor). Such
|
||||
* paths would get matched as `workspaceSlug="issues"` by the router and
|
||||
* render NoAccessPage. Sanitize by falling back to "/" (IndexRedirect picks
|
||||
* a valid workspace).
|
||||
* Defensive: catch paths that don't belong in the tab store.
|
||||
*
|
||||
* Passes through:
|
||||
* - "/" and global paths (/login, /workspaces/new, /invite/..., /auth/...)
|
||||
* - workspace-scoped paths whose first segment is not a reserved word
|
||||
* Two kinds of rejects:
|
||||
* 1. **Transition paths** (`/workspaces/new`, `/invite/...`). These are
|
||||
* pre-workspace flows rendered by the window overlay on desktop, not
|
||||
* tab routes. They must never persist as tab state — otherwise the user
|
||||
* reopens the app into a stale form/invite. Navigation to these paths
|
||||
* is intercepted by the navigation adapter, so they shouldn't reach
|
||||
* tab-store in normal flow; this guard catches older persisted state.
|
||||
* 2. **Malformed workspace-scoped paths** like a stray `/issues/abc` that
|
||||
* was constructed without the workspace prefix. Router would interpret
|
||||
* `issues` as a workspace slug → NoAccessPage.
|
||||
*
|
||||
* Rejects (and rewrites to "/"):
|
||||
* - Paths whose first segment is a reserved slug (=/=workspace slug), which
|
||||
* means the caller forgot to prefix the workspace. Logs a warning so the
|
||||
* buggy call site is easy to find.
|
||||
* Both rewrite to `/` so `IndexRedirect` picks a valid destination (or
|
||||
* opens the new-workspace overlay if the user has none).
|
||||
*/
|
||||
export function sanitizeTabPath(path: string): string {
|
||||
if (path === DEFAULT_PATH || isGlobalPath(path)) return path;
|
||||
if (path === DEFAULT_PATH) return path;
|
||||
const firstSegment = path.split("/").filter(Boolean)[0] ?? "";
|
||||
if (isReservedSlug(firstSegment)) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[tab-store] tab path "${path}" starts with reserved slug "${firstSegment}" — ` +
|
||||
`caller likely forgot the workspace prefix. Falling back to "/".`,
|
||||
);
|
||||
// Don't log for known transition paths — these are legitimate inputs
|
||||
// at the interception boundary (older persisted state or stale callers).
|
||||
// Log only for truly buggy cases where a view forgot the workspace prefix.
|
||||
const isTransition = path === "/workspaces/new" || path.startsWith("/invite/");
|
||||
if (!isTransition) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`[tab-store] tab path "${path}" starts with reserved slug "${firstSegment}" — ` +
|
||||
`caller likely forgot the workspace prefix. Falling back to "/".`,
|
||||
);
|
||||
}
|
||||
return DEFAULT_PATH;
|
||||
}
|
||||
return path;
|
||||
@@ -227,15 +237,15 @@ export const useTabStore = create<TabStore>()(
|
||||
const { tabs } = get();
|
||||
let changed = false;
|
||||
const nextTabs = tabs.map((t) => {
|
||||
// Skip tabs on non-workspace-scoped paths — nothing to validate.
|
||||
if (t.path === "/" || isGlobalPath(t.path)) return t;
|
||||
// Root sentinel doesn't carry a slug — nothing to validate.
|
||||
if (t.path === "/") return t;
|
||||
|
||||
const firstSegment = t.path.split("/").filter(Boolean)[0] ?? "";
|
||||
if (validSlugs.has(firstSegment)) return t;
|
||||
|
||||
// Stale slug: dispose the old router and replace with a fresh one
|
||||
// pointing at `/`. IndexRedirect will send the tab to a valid
|
||||
// workspace (or /workspaces/new if the user now has none).
|
||||
// workspace, or open the new-workspace overlay if none remain.
|
||||
changed = true;
|
||||
t.router.dispose();
|
||||
return {
|
||||
@@ -252,6 +262,13 @@ export const useTabStore = create<TabStore>()(
|
||||
if (!changed) return;
|
||||
set({ tabs: nextTabs });
|
||||
},
|
||||
|
||||
reset() {
|
||||
const { tabs } = get();
|
||||
for (const t of tabs) t.router.dispose();
|
||||
const fresh = makeTab(DEFAULT_PATH, "Issues", resolveRouteIcon(DEFAULT_PATH));
|
||||
set({ tabs: [fresh], activeTabId: fresh.id });
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "multica_tabs",
|
||||
|
||||
29
apps/desktop/src/renderer/src/stores/window-overlay-store.ts
Normal file
29
apps/desktop/src/renderer/src/stores/window-overlay-store.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
/**
|
||||
* Window-level transition overlay: pre-workspace flows that are NOT pages
|
||||
* inside a tab. Triggered by navigation-adapter interception, zero-workspace
|
||||
* auto-redirect, or deep link; rendered above the tab system as a full-window
|
||||
* takeover.
|
||||
*
|
||||
* These flows used to be routes (`/workspaces/new`, `/invite/:id`) but on
|
||||
* desktop the URL is invisible to users — routes are an implementation detail
|
||||
* of the tab system. Representing transitions as routes meant tabs tried to
|
||||
* persist them, TabBar rendered on top, and invite deep-linking had no clean
|
||||
* dispatch target. Modeling them as application state removes all three.
|
||||
*/
|
||||
export type WindowOverlay =
|
||||
| { type: "new-workspace" }
|
||||
| { type: "invite"; invitationId: string };
|
||||
|
||||
interface WindowOverlayStore {
|
||||
overlay: WindowOverlay | null;
|
||||
open: (overlay: WindowOverlay) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export const useWindowOverlayStore = create<WindowOverlayStore>((set) => ({
|
||||
overlay: null,
|
||||
open: (overlay) => set({ overlay }),
|
||||
close: () => set({ overlay: null }),
|
||||
}));
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter, useParams } from "next/navigation";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import { workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
import { InvitePage } from "@multica/views/invite";
|
||||
|
||||
export default function InviteAcceptPage() {
|
||||
@@ -11,6 +13,10 @@ export default function InviteAcceptPage() {
|
||||
const params = useParams<{ id: string }>();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const { data: wsList = [] } = useQuery({
|
||||
...workspaceListOptions(),
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
// Redirect to login if not authenticated, with a redirect back to this page.
|
||||
useEffect(() => {
|
||||
@@ -23,5 +29,8 @@ export default function InviteAcceptPage() {
|
||||
|
||||
if (isLoading || !user) return null;
|
||||
|
||||
return <InvitePage invitationId={params.id} />;
|
||||
const onBack =
|
||||
wsList.length > 0 ? () => router.push(paths.root()) : undefined;
|
||||
|
||||
return <InvitePage invitationId={params.id} onBack={onBack} />;
|
||||
}
|
||||
|
||||
@@ -2,14 +2,20 @@
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import { workspaceListOptions } from "@multica/core/workspace/queries";
|
||||
import { NewWorkspacePage } from "@multica/views/workspace/new-workspace-page";
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const isLoading = useAuthStore((s) => s.isLoading);
|
||||
const { data: wsList = [] } = useQuery({
|
||||
...workspaceListOptions(),
|
||||
enabled: !!user,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !user) router.replace(paths.login());
|
||||
@@ -17,9 +23,16 @@ export default function Page() {
|
||||
|
||||
if (isLoading || !user) return null;
|
||||
|
||||
// Back goes to the root path — the workspace layout redirects from
|
||||
// there to the user's default workspace. Only show Back when there's
|
||||
// somewhere to go back to (user already has at least one workspace).
|
||||
const onBack =
|
||||
wsList.length > 0 ? () => router.push(paths.root()) : undefined;
|
||||
|
||||
return (
|
||||
<NewWorkspacePage
|
||||
onSuccess={(ws) => router.push(paths.workspace(ws.slug).issues())}
|
||||
onBack={onBack}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api } from "@multica/core/api";
|
||||
import {
|
||||
@@ -9,15 +9,30 @@ import {
|
||||
} from "@multica/core/workspace/queries";
|
||||
import { paths } from "@multica/core/paths";
|
||||
import { useNavigation } from "../navigation";
|
||||
import { useLogout } from "../auth";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Card, CardContent } from "@multica/ui/components/ui/card";
|
||||
import { Users, Check, X } from "lucide-react";
|
||||
import { ArrowLeft, LogOut, Users, Check, X } from "lucide-react";
|
||||
|
||||
export interface InvitePageProps {
|
||||
invitationId: string;
|
||||
/**
|
||||
* Optional "go back" handler. Caller passes it only when there's a
|
||||
* sensible destination (user has at least one workspace, or arrived
|
||||
* from an in-app flow). Omitted on first-invite/zero-workspace paths
|
||||
* where Back would have nowhere to go — Log out is then the only exit.
|
||||
*/
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
export function InvitePage({ invitationId }: InvitePageProps) {
|
||||
/**
|
||||
* Full-page shell for the "accept invitation" transition. Shared between
|
||||
* web (Next.js route `/invite/[id]`) and desktop (window-overlay).
|
||||
* Top-bar affordances (Back, Log out) live here so both platforms get
|
||||
* identical UX. Platform chrome (window drag region, immersive mode) is
|
||||
* layered on by the desktop overlay; web just renders the page directly.
|
||||
*/
|
||||
export function InvitePage({ invitationId, onBack }: InvitePageProps) {
|
||||
const { push } = useNavigation();
|
||||
const qc = useQueryClient();
|
||||
const [accepting, setAccepting] = useState(false);
|
||||
@@ -78,15 +93,15 @@ export function InvitePage({ invitationId }: InvitePageProps) {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<InviteShell onBack={onBack}>
|
||||
<div className="text-sm text-muted-foreground">Loading invitation...</div>
|
||||
</div>
|
||||
</InviteShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (fetchError || !invitation) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<InviteShell onBack={onBack}>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="flex flex-col items-center gap-4 py-12">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||
@@ -101,13 +116,13 @@ export function InvitePage({ invitationId }: InvitePageProps) {
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</InviteShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (done === "accepted") {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<InviteShell onBack={onBack}>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="flex flex-col items-center gap-4 py-12">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
|
||||
@@ -117,13 +132,13 @@ export function InvitePage({ invitationId }: InvitePageProps) {
|
||||
<p className="text-sm text-muted-foreground">Redirecting to workspace...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</InviteShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (done === "declined") {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<InviteShell onBack={onBack}>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="flex flex-col items-center gap-4 py-12">
|
||||
<h2 className="text-lg font-semibold">Invitation declined</h2>
|
||||
@@ -133,7 +148,7 @@ export function InvitePage({ invitationId }: InvitePageProps) {
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</InviteShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -141,7 +156,7 @@ export function InvitePage({ invitationId }: InvitePageProps) {
|
||||
const isAlreadyHandled = invitation.status === "accepted" || invitation.status === "declined";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<InviteShell onBack={onBack}>
|
||||
<Card className="w-full max-w-md">
|
||||
<CardContent className="flex flex-col items-center gap-6 py-12">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-primary/10">
|
||||
@@ -191,6 +206,46 @@ export function InvitePage({ invitationId }: InvitePageProps) {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</InviteShell>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared chrome for every InvitePage render state (loading, error,
|
||||
* default, accepted, declined). Keeps Back + Log out buttons in a
|
||||
* consistent position across all branches and across platforms.
|
||||
*/
|
||||
function InviteShell({
|
||||
onBack,
|
||||
children,
|
||||
}: {
|
||||
onBack?: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const logout = useLogout();
|
||||
return (
|
||||
<div className="relative flex min-h-svh flex-col items-center justify-center bg-background px-6 py-12">
|
||||
{onBack && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-12 left-12 text-muted-foreground"
|
||||
onClick={onBack}
|
||||
>
|
||||
<ArrowLeft />
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-12 right-12 text-muted-foreground hover:text-destructive"
|
||||
onClick={logout}
|
||||
>
|
||||
<LogOut />
|
||||
Log out
|
||||
</Button>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,31 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowLeft, LogOut } from "lucide-react";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import type { Workspace } from "@multica/core/types";
|
||||
import { useLogout } from "../auth";
|
||||
import { CreateWorkspaceForm } from "./create-workspace-form";
|
||||
|
||||
/**
|
||||
* Full-page shell for the /workspaces/new route. Shared between web
|
||||
* (Next.js) and desktop (react-router) so the two apps can't drift.
|
||||
* Callers provide the onSuccess handler — that's the only app-specific
|
||||
* piece, because each app uses its own navigation primitive.
|
||||
* Full-page shell for the "create workspace" transition. Shared between web
|
||||
* (Next.js route `/workspaces/new`) and desktop (window-overlay). The
|
||||
* top-bar affordances — Back (when dismissable) and Log out — live here
|
||||
* so both platforms get identical UX; platform-specific concerns like
|
||||
* window-drag region and macOS traffic-light handling stay in each app's
|
||||
* shell.
|
||||
*
|
||||
* `onBack` is optional: caller passes it only when there's somewhere to go
|
||||
* back to (user has other workspaces, or the flow was entered from an
|
||||
* existing session). On the zero-workspace entry path it's omitted, which
|
||||
* hides Back — Log out is then the only escape.
|
||||
*/
|
||||
export function NewWorkspacePage({
|
||||
onSuccess,
|
||||
onBack,
|
||||
}: {
|
||||
onSuccess: (workspace: Workspace) => void;
|
||||
onBack?: () => void;
|
||||
}) {
|
||||
const logout = useLogout();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-svh flex-col items-center justify-center bg-background px-6 py-12">
|
||||
<div className="flex w-full max-w-md flex-col items-center gap-6">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">
|
||||
Welcome to Multica
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Create your workspace to get started.
|
||||
</p>
|
||||
<div className="relative flex min-h-svh flex-col bg-background px-6 py-12">
|
||||
{onBack && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-12 left-12 text-muted-foreground"
|
||||
onClick={onBack}
|
||||
>
|
||||
<ArrowLeft />
|
||||
Back
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="absolute top-12 right-12 text-muted-foreground hover:text-destructive"
|
||||
onClick={logout}
|
||||
>
|
||||
<LogOut />
|
||||
Log out
|
||||
</Button>
|
||||
|
||||
<div className="flex flex-1 flex-col items-center justify-center">
|
||||
<div className="flex w-full max-w-md flex-col items-center gap-6">
|
||||
<div className="text-center">
|
||||
<h1 className="text-3xl font-semibold tracking-tight">
|
||||
Welcome to Multica
|
||||
</h1>
|
||||
<p className="mt-2 text-muted-foreground">
|
||||
Create your workspace to get started.
|
||||
</p>
|
||||
</div>
|
||||
<CreateWorkspaceForm onSuccess={onSuccess} />
|
||||
</div>
|
||||
<CreateWorkspaceForm onSuccess={onSuccess} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user