fix(templates): ensure template use frontend css
this fix the nextcloud login + drive selector (chroot)
fix also invitation / magic link
also correct the UX: once user has logged in nextcloud, show an explicita page
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"postbuild": "node scripts/emit-askama-common.mjs",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json && eslint . && stylelint \"src/**/*.{css,svelte}\" && prettier --check .",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* Emit `static-dist/askama-common.css` from the SvelteKit design-token
|
||||
* source of truth (`src/lib/styles/base/variables.css`) plus the auth-page
|
||||
* component styles (`src/lib/styles/askama-common.css`).
|
||||
*
|
||||
* WHY A POST-BUILD SCRIPT:
|
||||
* Vite's `writeBundle` hooks fire mid-build, before
|
||||
* `@sveltejs/adapter-static` copies the finalised site to
|
||||
* `../static-dist/`. Anything written to that directory during
|
||||
* Vite gets wiped when adapter-static runs. A `postbuild` script
|
||||
* runs after everything the SvelteKit build owns, so its output
|
||||
* survives — one predictable moment, no ordering trap.
|
||||
*
|
||||
* WHAT IT PRODUCES:
|
||||
* A single stable-named CSS file at `static-dist/askama-common.css`
|
||||
* containing:
|
||||
* 1. Every design token declared in `base/variables.css` (:root,
|
||||
* `light-dark(...)`, dark-mode blocks, etc.)
|
||||
* 2. The auth-page component rules from `askama-common.css`
|
||||
* Concatenated, prefixed with a "do not edit" header, written UTF-8.
|
||||
*
|
||||
* SINGLE SOURCE OF TRUTH:
|
||||
* If a token changes in `variables.css`, one rebuild propagates it to
|
||||
* both the SPA (via Svelte's normal build pipeline) AND the askama
|
||||
* templates (via this file). Two consumers, one source. No manual
|
||||
* sync step.
|
||||
*
|
||||
* SERVER SIDE:
|
||||
* Server-rendered askama templates reference:
|
||||
* <link rel="stylesheet" href="/askama-common.css">
|
||||
* The Rust web layer serves `static-dist/askama-common.css` at that
|
||||
* URL through the same ServeDir the SPA uses. No route wiring needed.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const stylesDir = resolve(__dirname, '../src/lib/styles');
|
||||
const outputFile = resolve(__dirname, '../../static-dist/askama-common.css');
|
||||
|
||||
const header =
|
||||
'/* Auto-generated by frontend/scripts/emit-askama-common.mjs.\n' +
|
||||
' * Do NOT edit by hand — regenerated on every `npm run build`.\n' +
|
||||
' * Sources: src/lib/styles/base/variables.css (design tokens)\n' +
|
||||
' * src/lib/styles/askama-common.css (auth components)\n' +
|
||||
' */\n\n';
|
||||
|
||||
const tokens = readFileSync(resolve(stylesDir, 'base/variables.css'), 'utf8');
|
||||
const components = readFileSync(resolve(stylesDir, 'askama-common.css'), 'utf8');
|
||||
|
||||
mkdirSync(dirname(outputFile), { recursive: true });
|
||||
writeFileSync(outputFile, header + tokens + '\n' + components, 'utf8');
|
||||
|
||||
const bytes = Buffer.byteLength(header + tokens + '\n' + components, 'utf8');
|
||||
console.log(`emit-askama-common: wrote ${bytes} bytes → ${outputFile}`);
|
||||
@@ -0,0 +1,221 @@
|
||||
/*
|
||||
* askama-common.css — component styles for server-rendered askama pages.
|
||||
*
|
||||
* BUILD PIPELINE:
|
||||
* `vite.config.ts` prepends `base/variables.css` at build time (the
|
||||
* `emitAskamaCommon` plugin) and writes the result to
|
||||
* `static-dist/askama-common.css`. That output is the single non-hashed
|
||||
* URL every askama template references:
|
||||
*
|
||||
* <link rel="stylesheet" href="/askama-common.css">
|
||||
*
|
||||
* SINGLE SOURCE OF TRUTH:
|
||||
* Design tokens (`--color-*`, `--space-*`, `--radius-*`, `--text-*`,
|
||||
* etc.) live in `base/variables.css`. This file only carries the
|
||||
* component-level rules for the class vocabulary the askama templates
|
||||
* actually use. Update tokens in ONE place; the build packages both.
|
||||
*
|
||||
* NO JAVASCRIPT:
|
||||
* Dark-mode detection uses `light-dark()` + the `color-scheme` on
|
||||
* :root (declared in `variables.css`). Askama pages are pre-auth flows
|
||||
* (login / magic-link error) — no per-user override needed. Browsers
|
||||
* older than Chrome 123 / Safari 17.5 / Firefox 120 fall back to the
|
||||
* light values; the pages are readable either way.
|
||||
*
|
||||
* CLASS VOCABULARY (mirrors `grep 'class=' templates/**\/*.html`):
|
||||
* .auth-container .auth-panel
|
||||
* .auth-logo .auth-logo-icon .auth-logo-text
|
||||
* .auth-title .auth-subtitle
|
||||
* .auth-form .auth-button
|
||||
* .auth-drive-option .auth-drive-name .auth-drive-badge
|
||||
* .magic-note
|
||||
*/
|
||||
|
||||
/* ── Reset ─────────────────────────────────────────────────────── */
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
font-size: var(--text-base);
|
||||
line-height: var(--leading-normal);
|
||||
background: var(--color-bg-page);
|
||||
color: var(--color-text);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ── Layout shell ──────────────────────────────────────────────── */
|
||||
|
||||
.auth-container {
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.auth-panel {
|
||||
width: min(400px, 100%);
|
||||
background: var(--color-bg-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-2xl);
|
||||
box-shadow: var(--shadow-md);
|
||||
padding: var(--space-8);
|
||||
}
|
||||
|
||||
/* ── Logo strip ───────────────────────────────────────────────── */
|
||||
|
||||
.auth-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.auth-logo-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-accent);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.auth-logo-icon svg {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.auth-logo-text {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--color-text-heading);
|
||||
}
|
||||
|
||||
/* ── Title strip ──────────────────────────────────────────────── */
|
||||
|
||||
.auth-title {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-size: var(--text-xl);
|
||||
font-weight: var(--weight-semibold);
|
||||
color: var(--color-text-heading);
|
||||
line-height: var(--leading-snug);
|
||||
}
|
||||
|
||||
.auth-subtitle {
|
||||
margin: 0 0 var(--space-5);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.auth-subtitle a {
|
||||
color: var(--color-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.auth-subtitle a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ── Form + button ────────────────────────────────────────────── */
|
||||
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.auth-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-accent);
|
||||
color: var(--color-on-accent);
|
||||
font: inherit;
|
||||
font-weight: var(--weight-semibold);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease;
|
||||
}
|
||||
|
||||
.auth-button:hover {
|
||||
background: var(--color-accent-hover);
|
||||
}
|
||||
|
||||
.auth-button:focus-visible {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ── Drive picker radios ──────────────────────────────────────── */
|
||||
|
||||
.auth-drive-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.12s ease,
|
||||
background 0.12s ease;
|
||||
}
|
||||
|
||||
.auth-drive-option:hover {
|
||||
border-color: var(--color-border-medium);
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
.auth-drive-option:has(input:checked) {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--color-accent-ring);
|
||||
}
|
||||
|
||||
.auth-drive-option input[type='radio'] {
|
||||
accent-color: var(--color-accent);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-drive-name {
|
||||
flex: 1;
|
||||
font-weight: var(--weight-medium);
|
||||
}
|
||||
|
||||
.auth-drive-badge {
|
||||
padding: 2px var(--space-2);
|
||||
border-radius: 999px;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-on-accent);
|
||||
font-size: 0.6875rem;
|
||||
font-weight: var(--weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
/* ── Magic-link note block ────────────────────────────────────── */
|
||||
|
||||
.magic-note {
|
||||
margin-top: var(--space-5);
|
||||
padding: var(--space-3) var(--space-4);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-bg-input);
|
||||
border: 1px solid var(--color-border);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
@@ -67,7 +67,7 @@
|
||||
<svelte:head><title>{view.title} · OxiCloud</title></svelte:head>
|
||||
|
||||
<main class="nc-status">
|
||||
<Icon name="ban" class="nc-status__icon nc-status__icon--err" />
|
||||
<Icon name="exclamation-circle" class="nc-status__icon nc-status__icon--err" />
|
||||
<h1>{view.title}</h1>
|
||||
<p>{view.message}</p>
|
||||
<button
|
||||
@@ -80,7 +80,13 @@
|
||||
|
||||
<style>
|
||||
.nc-status {
|
||||
min-height: 100vh;
|
||||
/* `base/reset.css` sets `body { display: flex }`. Public
|
||||
`/nextcloud/*` routes render children directly (bypassing
|
||||
AppShell), so <main> is a flex item on the body's row axis
|
||||
and needs to claim the full slot for its own centering to
|
||||
land in the viewport middle. */
|
||||
flex: 1;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -95,7 +101,12 @@
|
||||
}
|
||||
|
||||
:global(.nc-status__icon--err) {
|
||||
color: var(--color-danger-text);
|
||||
/* `--color-danger-text` is white — it's the foreground for text
|
||||
sitting on a red button bg, not the standalone red glyph
|
||||
colour. `--color-error-text` is the light-dark(...) red pair
|
||||
designed for standalone use on the page background: darker
|
||||
red in light mode, softer coral in dark mode. */
|
||||
color: var(--color-error-text);
|
||||
}
|
||||
|
||||
.nc-status__action {
|
||||
|
||||
@@ -8,8 +8,12 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Mirror the legacy flow: auto-close the popup shortly after success so
|
||||
// the user is returned to their Nextcloud client without an extra click.
|
||||
// Auto-close the tab a few seconds after landing. NC clients
|
||||
// receive their credentials through the LFv2 poll endpoint
|
||||
// (`/login/v2/poll`) in the backchannel — this browser tab is
|
||||
// only useful as a "flow succeeded" landing. Users who want
|
||||
// to keep it around click nothing; users who want it gone
|
||||
// get it gone automatically.
|
||||
const timer = setTimeout(closeWindow, 3000);
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
@@ -34,7 +38,13 @@
|
||||
|
||||
<style>
|
||||
.nc-status {
|
||||
min-height: 100vh;
|
||||
/* `base/reset.css` sets `body { display: flex }`. Public
|
||||
`/nextcloud/*` routes render children directly (bypassing
|
||||
AppShell), so <main> is a flex item on the body's row axis
|
||||
and needs to claim the full slot for its own centering to
|
||||
land in the viewport middle. */
|
||||
flex: 1;
|
||||
min-height: 100dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
@@ -3,6 +3,13 @@ import { defineConfig } from 'vitest/config';
|
||||
import istanbul from 'vite-plugin-istanbul';
|
||||
import { svelteTesting } from '@testing-library/svelte/vite';
|
||||
|
||||
// `static-dist/askama-common.css` is emitted by `scripts/emit-askama-common.mjs`,
|
||||
// wired into `package.json` as a `postbuild` step. That runs AFTER
|
||||
// `@sveltejs/adapter-static` finalises `static-dist/`, avoiding the
|
||||
// wipe-and-copy race that would eat any file a `writeBundle` hook wrote
|
||||
// during the Vite build phase. See the script header for the pipeline
|
||||
// rationale and the single-source-of-truth invariant it preserves.
|
||||
|
||||
// Backend dev server (cargo run) — the Vite dev server proxies API/protocol
|
||||
// traffic here so cookies, CSRF, and the auth-refresh flow are same-origin.
|
||||
const BACKEND = process.env.OXICLOUD_BACKEND ?? 'http://localhost:8086';
|
||||
|
||||
@@ -332,11 +332,30 @@ async fn complete_flow(
|
||||
base_url = %base_url,
|
||||
"Login Flow v2: flow completed successfully"
|
||||
);
|
||||
let nc_url = format!(
|
||||
"nc://login/server:{}&user:{}&password:{}",
|
||||
base_url, login_name, app_password
|
||||
);
|
||||
axum::response::Redirect::to(&nc_url).into_response()
|
||||
// Redirect the browser to a visible success page. NC clients
|
||||
// that use the LFv2 poll endpoint (the standard pattern) have
|
||||
// already received the credentials server-to-server through
|
||||
// `login_flow.complete()` above — they don't need any browser
|
||||
// hand-off.
|
||||
//
|
||||
// We deliberately do NOT redirect to `nc://login/…` here:
|
||||
// 1. Plain browsers can't follow it → the tab looks stuck
|
||||
// on the picker → user clicks Continue again → second
|
||||
// click hits an already-consumed flow token → ends up
|
||||
// on `/nextcloud/error?type=session-expired`.
|
||||
// 2. NC desktop clients that pick it up while their poll
|
||||
// has already succeeded try to complete the flow a
|
||||
// second time, which fails validation ("Impossible de
|
||||
// valider la requête") — the poll session is fine, the
|
||||
// dialog is spurious noise.
|
||||
//
|
||||
// If a client ever needs a frontchannel `nc://` handoff
|
||||
// (older NC releases, mobile), reintroduce the URL as a
|
||||
// client-side-only fragment (`#target=…`) and add a manual
|
||||
// "Open Nextcloud" fallback on the success page. Keep the
|
||||
// credentials out of the query string either way — the query
|
||||
// string reaches server access logs.
|
||||
axum::response::Redirect::to("/nextcloud/success").into_response()
|
||||
} else {
|
||||
tracing::error!(
|
||||
user = %user.username,
|
||||
|
||||
@@ -6,23 +6,19 @@
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>OxiCloud</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/logo/logo-plain.svg">
|
||||
<script src="/js/core/theme-init.js"></script>
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
<link rel="stylesheet" href="/css/views/auth.css">
|
||||
<link rel="stylesheet" href="/askama-common.css">
|
||||
<style>
|
||||
/* Cross-browser prompt is the one magic-link page that needs a
|
||||
"warning" callout that auth.css doesn't ship. Inline because the
|
||||
shape is unique to this surface — keep the styling local rather
|
||||
than adding tokens that no other page reuses. */
|
||||
/* Cross-browser prompt overrides `.magic-note` with a warning
|
||||
treatment — this is the only page where the note should shout.
|
||||
Tokens come from the shared design system (variables.css) via
|
||||
askama-common.css above; both are the canonical warning names
|
||||
(`--color-warning-bg` / `--color-warning-border`), replacing the
|
||||
pre-SPA-migration placeholders `--color-warning-bg-light` /
|
||||
`--color-warning-text-amber` which never existed. */
|
||||
.magic-note {
|
||||
background: var(--color-warning-bg-light);
|
||||
border-left: 3px solid var(--color-warning-text-amber);
|
||||
background: var(--color-warning-bg);
|
||||
border-left: 3px solid var(--color-warning-border);
|
||||
color: var(--color-text);
|
||||
padding: 0.75em 1em;
|
||||
margin: 1.5em 0;
|
||||
border-radius: 8px;
|
||||
font-size: 0.95em;
|
||||
text-align: left;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
@@ -6,9 +6,7 @@
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>OxiCloud</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/logo/logo-plain.svg">
|
||||
<script src="/js/core/theme-init.js"></script>
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
<link rel="stylesheet" href="/css/views/auth.css">
|
||||
<link rel="stylesheet" href="/askama-common.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-container">
|
||||
|
||||
@@ -6,9 +6,7 @@
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>OxiCloud</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/logo/logo-plain.svg">
|
||||
<script src="/js/core/theme-init.js"></script>
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
<link rel="stylesheet" href="/css/views/auth.css">
|
||||
<link rel="stylesheet" href="/askama-common.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-container">
|
||||
|
||||
@@ -6,9 +6,7 @@
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>OxiCloud</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/logo/logo-plain.svg">
|
||||
<script src="/js/core/theme-init.js"></script>
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
<link rel="stylesheet" href="/css/views/auth.css">
|
||||
<link rel="stylesheet" href="/askama-common.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-container">
|
||||
|
||||
@@ -6,9 +6,7 @@
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>Choose a drive - OxiCloud</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/logo/logo-plain.svg">
|
||||
<script src="/js/core/theme-init.js"></script>
|
||||
<link rel="stylesheet" href="/css/main.css">
|
||||
<link rel="stylesheet" href="/css/views/auth.css">
|
||||
<link rel="stylesheet" href="/askama-common.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="auth-container">
|
||||
|
||||
Reference in New Issue
Block a user