feat(external): permit login via email (magic link)

This commit is contained in:
Edouard Vanbelle
2026-06-02 13:45:37 +02:00
parent 458232354b
commit 64d081ad0b
8 changed files with 418 additions and 2 deletions
+30
View File
@@ -183,6 +183,36 @@
font-size: 14px;
}
/* Helper text above the magic-link form ("No password? Enter your
email…"). Quieter visual weight than the form labels. */
.auth-hint {
margin: 0 0 12px;
font-size: 13px;
line-height: 1.4;
color: var(--color-text-secondary);
}
/* Status banner under the magic-link form. Uniform anti-enumeration
message rendered on every successful 2xx; error variant only used
for the 503-not-configured branch or network failures. */
.auth-status {
margin-top: 12px;
padding: 10px 14px;
border-radius: 8px;
font-size: 13px;
line-height: 1.4;
}
.auth-status-success {
background: var(--color-bg-hover);
color: var(--color-text);
border-left: 3px solid var(--color-warning-orange-text);
}
.auth-status-error {
background: var(--color-bg-hover);
color: var(--color-text);
border-left: 3px solid var(--color-warning-orange-text);
}
/* Divider between password and SSO login */
.auth-divider {
display: flex;
+48
View File
@@ -1072,6 +1072,54 @@ if (isLoginPage && registerForm) {
}
// Admin setup form submission
// Magic-link form: anti-enumeration sign-in by email.
// The server always responds 200 with a uniform message when SMTP is
// configured, regardless of whether the email maps to an account or
// whether that account is eligible for magic-link sign-in. The UI
// mirrors that — same success state for every successful 2xx — so
// the page can't be used as an oracle. 503 is the one exception
// (SMTP not configured); operators need to see it.
const magicLinkForm = /** @type {HTMLFormElement | null} */ (document.getElementById('magic-link-form'));
const magicLinkStatus = document.getElementById('magic-link-status');
const magicLinkSubmit = /** @type {HTMLButtonElement | null} */ (document.getElementById('magic-link-submit'));
if (isLoginPage && magicLinkForm && magicLinkStatus) {
magicLinkForm.addEventListener('submit', async (e) => {
e.preventDefault();
const email = inputVal('magic-link-email');
if (!email) return;
magicLinkStatus.className = 'auth-status hidden';
magicLinkStatus.textContent = '';
if (magicLinkSubmit) magicLinkSubmit.disabled = true;
try {
const resp = await fetch('/api/auth/magic-link/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...getCsrfHeaders() },
body: JSON.stringify({ email })
});
if (resp.status === 503) {
magicLinkStatus.className = 'auth-status auth-status-error';
magicLinkStatus.textContent = i18n.t('auth.magicLinkUnavailable', 'Sign-in by email is not available on this server.');
return;
}
// Any 2xx → uniform success message regardless of whether
// the server actually queued a mail. Anti-enumeration.
magicLinkStatus.className = 'auth-status auth-status-success';
magicLinkStatus.textContent = i18n.t('auth.magicLinkSent', 'If an account exists for that email, a sign-in link has been sent. Check your inbox.');
magicLinkForm.reset();
} catch (err) {
magicLinkStatus.className = 'auth-status auth-status-error';
magicLinkStatus.textContent = i18n.t('auth.magicLinkNetworkError', {
message: /** @type {Error} */ (err).message
});
} finally {
if (magicLinkSubmit) magicLinkSubmit.disabled = false;
magicLinkStatus.classList.remove('hidden');
}
});
}
if (isLoginPage && adminSetupForm) {
adminSetupForm.addEventListener('submit', async (e) => {
e.preventDefault();
+8 -1
View File
@@ -412,7 +412,14 @@
"admin_create_error": "Error creating administrator account",
"or": "or",
"sso_login": "Sign in with SSO",
"sso_login_provider": "Sign in with {{provider}}"
"sso_login_provider": "Sign in with {{provider}}",
"magicLinkHint": "No password? Enter your email and we'll send you a one-time sign-in link.",
"magicLinkEmailLabel": "Email address",
"magicLinkEmailPlaceholder": "you@example.com",
"magicLinkSubmit": "Send sign-in link",
"magicLinkSent": "If an account exists for that email, a sign-in link has been sent. Check your inbox.",
"magicLinkUnavailable": "Sign-in by email is not available on this server.",
"magicLinkNetworkError": "Could not reach the server: {{message}}"
},
"storage": {
"title": "Storage",
+8 -1
View File
@@ -412,7 +412,14 @@
"admin_create_error": "Erreur lors de la création du compte administrateur",
"or": "ou",
"sso_login": "Se connecter avec SSO",
"sso_login_provider": "Se connecter avec {{provider}}"
"sso_login_provider": "Se connecter avec {{provider}}",
"magicLinkHint": "Pas de mot de passe ? Saisissez votre adresse e-mail et nous vous enverrons un lien de connexion à usage unique.",
"magicLinkEmailLabel": "Adresse e-mail",
"magicLinkEmailPlaceholder": "vous@exemple.com",
"magicLinkSubmit": "Envoyer le lien de connexion",
"magicLinkSent": "Si un compte existe pour cette adresse, un lien de connexion vient d'être envoyé. Consultez votre boîte de réception.",
"magicLinkUnavailable": "La connexion par e-mail n'est pas disponible sur ce serveur.",
"magicLinkNetworkError": "Impossible de joindre le serveur : {{message}}"
},
"storage": {
"title": "Stockage",
+36
View File
@@ -108,6 +108,42 @@
<span data-i18n="auth.sso_login">Sign in with SSO</span>
</button>
</div>
<!-- Sign-in by email link (magic-link). For users without a
password (external recipients invited via share, future
passwordless-only accounts). Always rendered — the
endpoint is anti-enumeration so showing it doesn't leak
anything; if SMTP isn't configured the server returns
503 and we show a generic "unavailable" message. -->
<div id="magic-link-section">
<div class="auth-divider">
<span data-i18n="auth.or">or</span>
</div>
<p class="auth-hint" data-i18n="auth.magicLinkHint">
No password? Enter your email and we'll send you a one-time sign-in link.
</p>
<form class="auth-form" id="magic-link-form">
<div class="auth-input-group">
<label class="auth-label" for="magic-link-email" data-i18n="auth.magicLinkEmailLabel">
Email address
</label>
<input
type="email"
id="magic-link-email"
class="auth-input"
name="email"
required
autocomplete="email"
placeholder="you@example.com"
data-i18n-placeholder="auth.magicLinkEmailPlaceholder"
>
</div>
<button type="submit" class="auth-button" id="magic-link-submit" data-i18n="auth.magicLinkSubmit">
Send sign-in link
</button>
</form>
<div id="magic-link-status" class="auth-status hidden" role="status"></div>
</div>
<div class="auth-toggle">
<span data-i18n="auth.no_account">Don't have an account?</span>