Files
numolus/app/frontend/components/LoginModal.vue
T
wkhandClaude Opus 5.5 e14bada114 Add Rails backend: accounts via magic link, server documents, sharing
The app becomes a Rails 8 application; the Vue frontend moves to
app/frontend and is served through vite_rails.

- Without an account documents stay in the browser (IndexedDB). Sign-in
  is by magic link or the 6-digit code from the same mail; afterwards
  local documents can be uploaded into the account.
- Server documents (/d/<id>) with versioned saves and a single-editor
  lock per browser tab; others see them read-only and follow by polling.
- Sharing via link (works without an account) and via e-mail invitation,
  roles viewer/editor; the owner manages links and people.
- Store gains a read-only mode that blocks all content changes.
- Solid Queue, Cache and Cable share the one PostgreSQL database.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 20:09:06 +02:00

161 lines
4.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* Sign-in without a password: e-mail → a mail with a link and a 6-digit
* code. Clicking the link signs in (in whatever browser opens it); typing
* the code here signs in this browser. Both create the account on first use.
*/
import { nextTick, ref, watch } from 'vue';
import { useSession } from '../store/session';
import BaseModal from './BaseModal.vue';
const props = defineProps<{ open: boolean; reason?: string }>();
const emit = defineEmits<{ (e: 'close'): void }>();
const session = useSession();
const step = ref<'email' | 'code'>('email');
const email = ref('');
const code = ref('');
const busy = ref(false);
const error = ref<string | null>(null);
const emailInput = ref<HTMLInputElement | null>(null);
const codeInput = ref<HTMLInputElement | null>(null);
watch(
() => props.open,
async (o) => {
if (!o) return;
step.value = 'email';
code.value = '';
error.value = null;
await nextTick();
emailInput.value?.focus();
},
);
async function send() {
if (!email.value.trim()) return;
busy.value = true;
error.value = null;
try {
await session.requestLink(email.value.trim());
step.value = 'code';
await nextTick();
codeInput.value?.focus();
} catch (err) {
error.value = (err as Error).message.includes('422')
? 'Bitte eine gültige E-Mail-Adresse eingeben.'
: (err as Error).message;
} finally {
busy.value = false;
}
}
async function verify() {
busy.value = true;
error.value = null;
try {
await session.verifyCode(email.value.trim(), code.value);
} catch {
error.value = 'Der Code stimmt nicht oder ist abgelaufen.';
busy.value = false;
}
}
</script>
<template>
<BaseModal :open="open" title="Anmelden" @close="emit('close')">
<template v-if="step === 'email'">
<p v-if="reason" class="reason">{{ reason }}</p>
<p class="lead">
Mit einem Konto liegen deine Dokumente auf dem Server – auf allen Geräten verfügbar und teilbar.
Du bekommst einen Anmeldelink per E-Mail, ein Passwort gibt es nicht.
</p>
<form @submit.prevent="send">
<label class="row">
E-Mail
<input ref="emailInput" v-model="email" type="email" autocomplete="email" placeholder="name@beispiel.li" required />
</label>
</form>
</template>
<template v-else>
<p class="lead">
Wir haben eine Mail an <strong>{{ email }}</strong> geschickt. Klicke auf den Link darin – oder gib hier
den 6-stelligen Code ein.
</p>
<form @submit.prevent="verify">
<label class="row">
Code
<input
ref="codeInput"
v-model="code"
class="code"
inputmode="numeric"
autocomplete="one-time-code"
maxlength="7"
placeholder="123456"
/>
</label>
</form>
<button class="linkish" @click="step = 'email'">Andere Adresse / neuen Link anfordern</button>
</template>
<p v-if="error" class="error">{{ error }}</p>
<template #footer>
<button @click="emit('close')">Abbrechen</button>
<button v-if="step === 'email'" class="primary" :disabled="busy || !email.trim()" @click="send">
Link senden
</button>
<button v-else class="primary" :disabled="busy || code.replace(/\D/g, '').length !== 6" @click="verify">
Anmelden
</button>
</template>
</BaseModal>
</template>
<style scoped>
.lead,
.reason {
margin: 0 0 14px;
font-size: 13px;
line-height: 1.5;
}
.reason {
font-weight: 600;
}
.row {
display: flex;
flex-direction: column;
gap: 5px;
font-size: 12.5px;
color: var(--text-dim);
}
.row input {
border: 1px solid var(--border);
border-radius: 6px;
padding: 7px 9px;
font: inherit;
font-size: 14px;
background: var(--surface-2);
color: var(--text);
}
.code {
letter-spacing: 0.25em;
font-variant-numeric: tabular-nums;
}
.linkish {
margin-top: 10px;
border: 0;
background: none;
padding: 0;
font: inherit;
font-size: 12.5px;
color: var(--accent);
cursor: pointer;
}
.error {
margin: 10px 0 0;
font-size: 12.5px;
color: #dc2626;
}
</style>