CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 2s
Android / Kotlin + Rust (APK) (push) Skipped
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Web typecheck and unit tests (push) Successful in 8s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 47s
CI & Build / Build & push image (push) Successful in 54s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m8s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m28s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m14s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Until now adding a second person meant re-opening registration to the whole internet while they signed up (#2939 §1). An admin now makes an invite in Settings: a link that works once, expires (7 days by default, 1 to 30), and can be pinned to one email address. Only the token's hash is stored, so the link is shown once. POST /api/auth/register takes `invite`. Redemption is one conditional UPDATE inside the transaction that creates the account, so two people racing one link can't both get in, and a taken email leaves the invite unused. Every refusal says "invalid or expired invite". The register page reads ?invite= and opens even while registration is closed. Refs #5172 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
127 lines
5.1 KiB
TypeScript
127 lines
5.1 KiB
TypeScript
import { createRouter, createWebHistory } from "vue-router";
|
|
import { useSessionStore } from "../stores/session";
|
|
import { useConfigStore } from "../stores/config";
|
|
import { isDesktop, logEvent } from "../desktop/bridge";
|
|
|
|
// One-time boot diagnostic: the first navigation is where config + session resolve,
|
|
// so it's the moment that tells us whether the app got past its startup gate.
|
|
let bootLogged = false;
|
|
|
|
const router = createRouter({
|
|
history: createWebHistory(),
|
|
routes: [
|
|
{
|
|
// Persistent authed shell (sidebar + top bar + search); children render in it.
|
|
path: "/",
|
|
component: () => import("../components/AppShell.vue"),
|
|
meta: { requiresAuth: true },
|
|
children: [
|
|
{ path: "", name: "board", component: () => import("../views/BoardView.vue") },
|
|
{ path: "archive", name: "archive", component: () => import("../views/BoardView.vue") },
|
|
{ path: "trash", name: "trash", component: () => import("../views/BoardView.vue") },
|
|
{ path: "label/:id", name: "label", component: () => import("../views/BoardView.vue") },
|
|
{ path: "reminders", name: "reminders", component: () => import("../views/RemindersView.vue") },
|
|
{ path: "timeline", name: "timeline", component: () => import("../views/TimelineView.vue") },
|
|
],
|
|
},
|
|
{
|
|
// The quick-capture window (#1899). Its own route because it is its own
|
|
// WINDOW — no shell, no nav, one field. Desktop only: there is no global
|
|
// hotkey in a browser tab and nothing to summon it.
|
|
path: "/capture",
|
|
name: "capture",
|
|
component: () => import("../views/CaptureView.vue"),
|
|
meta: { title: "Quick capture", requiresAuth: true, requiresDesktop: true },
|
|
},
|
|
{
|
|
path: "/settings",
|
|
name: "settings",
|
|
component: () => import("../views/SettingsView.vue"),
|
|
meta: { title: "Settings", requiresAuth: true, requiresAdmin: true },
|
|
},
|
|
{
|
|
// Desktop only: connect this app to a server. Meaningless in the web build,
|
|
// which IS a server's UI — there's nothing for it to link to.
|
|
path: "/sync",
|
|
name: "sync",
|
|
component: () => import("../views/SyncView.vue"),
|
|
meta: { title: "Sync", requiresAuth: true, requiresDesktop: true },
|
|
},
|
|
{
|
|
// Per-user account: linked devices (native-client sync tokens). Any user.
|
|
//
|
|
// The mirror of `requiresDesktop` above: this one needs a SERVER. The desktop
|
|
// is itself one of the devices this page lists, so offline the list is always
|
|
// empty and issuing a token rejects — its server relationship lives at /sync.
|
|
// Guarded in the router, not just hidden in the shell, so a typed URL or a
|
|
// restored history entry can't land on a dead end either.
|
|
path: "/account",
|
|
name: "account",
|
|
component: () => import("../views/AccountView.vue"),
|
|
meta: { title: "Linked devices", requiresAuth: true, requiresServer: true },
|
|
},
|
|
{
|
|
path: "/login",
|
|
name: "login",
|
|
component: () => import("../views/LoginView.vue"),
|
|
meta: { title: "Sign in", guestOnly: true },
|
|
},
|
|
{
|
|
path: "/register",
|
|
name: "register",
|
|
component: () => import("../views/RegisterView.vue"),
|
|
meta: { title: "Create account", guestOnly: true },
|
|
},
|
|
],
|
|
});
|
|
|
|
router.beforeEach(async (to) => {
|
|
const session = useSessionStore();
|
|
const config = useConfigStore();
|
|
await config.load();
|
|
if (!session.loaded) {
|
|
await session.fetchMe();
|
|
}
|
|
if (!bootLogged) {
|
|
bootLogged = true;
|
|
logEvent(
|
|
"info",
|
|
`first route: config(site=${config.siteName}) session(user=${session.user?.email ?? "none"}) -> ${String(to.name ?? to.path)}`,
|
|
);
|
|
}
|
|
if (to.meta.requiresAuth && !session.user) {
|
|
return { name: "login", query: to.fullPath !== "/" ? { redirect: to.fullPath } : undefined };
|
|
}
|
|
if (to.meta.requiresAdmin && !session.user?.is_admin) {
|
|
return { name: "board" };
|
|
}
|
|
if (to.meta.requiresDesktop && !isDesktop()) {
|
|
return { name: "board" };
|
|
}
|
|
// The capture window is opened at `index.html?capture=1` rather than at
|
|
// `/capture`, because the bundled assets are served as files and a path with no
|
|
// file behind it 404s in the production build — it only routes under the dev
|
|
// server. A query string survives that, and this is where it becomes a route.
|
|
if (to.query.capture === "1" && to.name !== "capture") {
|
|
return { name: "capture" };
|
|
}
|
|
// Deliberately NOT applied to /login and /register: bouncing those on desktop
|
|
// would loop against the requiresAuth guard above the moment a session is
|
|
// missing. Nothing on the desktop navigates to them any more (AppShell's sign-out
|
|
// is web-only), and a fresh launch always resolves the local user.
|
|
if (to.meta.requiresServer && isDesktop()) {
|
|
return { name: "board" };
|
|
}
|
|
// An invite link opens the form while registration is closed; the server decides
|
|
// whether the invite holds.
|
|
if (to.name === "register" && !config.allowRegistration && !to.query.invite) {
|
|
return { name: "login" };
|
|
}
|
|
if (to.meta.guestOnly && session.user) {
|
|
return { name: "board" };
|
|
}
|
|
return true;
|
|
});
|
|
|
|
export default router;
|