Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions apps/app/app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ useHead({
<NuxtRouteAnnouncer />
<div class="flex min-h-screen flex-col">
<!-- Part of the flow rather than fixed over it: on a screen shorter than the content, a floating bar has scrolled text passing behind it. Upstream project and its documentation, not the running instance — a fork is free to repoint both, but the links are attribution. "GitHub" stays out of i18n, a brand name reads the same in every locale, while "Documentation" does not. -->
<header class="flex items-center justify-between gap-2 p-4">
<div class="flex items-center gap-1">
<!-- A phone header holds either these two or a labelled way to sign up, and signing up wins; what stays drops its own label below `sm`. -->
<header class="flex items-center justify-end gap-2 p-4 sm:justify-between">
<div class="hidden items-center gap-1 sm:flex">
<UButton
variant="ghost"
size="sm"
Expand Down
9 changes: 9 additions & 0 deletions apps/app/app/assets/css/main.css
Original file line number Diff line number Diff line change
@@ -1,2 +1,11 @@
@import "tailwindcss";
@import "@nuxt/ui";

/* Safari on iOS zooms the whole page whenever a focused field sits under 16px, and every Nuxt UI
control is 14px. Only on touch pointers, and only `!important` because the size comes from a class. */
@media (pointer: coarse) {
input:not([type="checkbox"]):not([type="radio"]),
textarea {
font-size: 1rem !important;
}
}
61 changes: 45 additions & 16 deletions apps/app/app/components/AuthButton.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,27 @@
<script setup lang="ts">
import type { DropdownMenuItem } from '@nuxt/ui'

const { t } = useI18n()
const localePath = useLocalePath()

await ensureAuthSessionLoaded()
const user = useAuthUser()
const isAuthenticated = computed(() => !!user.value)

// Signing up was only reachable from inside the login page, which hid it from anyone who has no account yet.
const publicSettings = await ensurePublicSettingsLoaded()
const canRegister = computed(() => publicSettings.value?.registrationEnabled === true)
Comment thread
thoda-dev marked this conversation as resolved.

// Everything an account can reach hangs off this one menu, so no page has to carry its own shortcut row.
const items = computed<DropdownMenuItem[]>(() => [
{ label: t('account.title'), icon: 'i-lucide-user', to: localePath('/account') },
{ label: t('dashboard.title'), icon: 'i-lucide-list', to: localePath('/dashboard') },
// The whole admin area opens from its settings page, which an admin may enter as well as a super admin.
...(user.value && ['admin', 'super_admin'].includes(user.value.role)
? [{ label: t('admin.settings.title'), icon: 'i-lucide-settings', to: localePath('/admin/settings') }]
: [])
])

async function logout() {
// Better Auth's sign-out rejects with 415/400 unless given a real JSON body.
await $fetch('/api/auth/sign-out', { method: 'POST', body: {} })
Expand All @@ -17,29 +33,42 @@ async function logout() {

<template>
<template v-if="isAuthenticated">
<UDropdownMenu :items="items">
<UButton
variant="ghost"
size="sm"
icon="i-lucide-user"
trailing-icon="i-lucide-chevron-down"
:aria-label="t('account.title')"
/>
</UDropdownMenu>
<UButton
color="error"
variant="subtle"
size="sm"
icon="i-lucide-log-out"
:aria-label="t('login.logout')"
@click="logout"
>
<span class="hidden sm:inline">{{ t('login.logout') }}</span>
</UButton>
</template>
<template v-else>
<UButton
v-if="canRegister"
variant="ghost"
size="sm"
icon="i-lucide-user"
:to="localePath('/account')"
:aria-label="t('account.title')"
icon="i-lucide-user-plus"
:label="t('register.title')"
:to="localePath('/register')"
/>
<UButton
color="error"
color="primary"
variant="subtle"
size="sm"
icon="i-lucide-log-out"
:label="t('login.logout')"
@click="logout"
icon="i-lucide-log-in"
:label="t('login.title')"
:to="localePath('/login')"
/>
</template>
<UButton
v-else
color="primary"
variant="subtle"
size="sm"
icon="i-lucide-log-in"
:label="t('login.title')"
:to="localePath('/login')"
/>
</template>
17 changes: 17 additions & 0 deletions apps/app/app/components/BackButton.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<script setup lang="ts">
const { t } = useI18n()
const localePath = useLocalePath()
</script>

<template>
<!-- Nine pages carry the same return path, and the label only fits next to a title from `sm` up. -->
<UButton
variant="ghost"
icon="i-lucide-arrow-left"
:aria-label="t('dashboard.backToCreate')"
:to="localePath('/')"
class="shrink-0"
>
<span class="hidden sm:inline">{{ t('dashboard.backToCreate') }}</span>
</UButton>
</template>
34 changes: 21 additions & 13 deletions apps/app/app/components/LanguageSwitcher.vue
Original file line number Diff line number Diff line change
@@ -1,23 +1,31 @@
<script setup lang="ts">
const { locale, locales, setLocale } = useI18n()
import type { DropdownMenuItem } from '@nuxt/ui'

const items = computed(() => locales.value.map(l => ({ label: l.name ?? l.code, value: l.code })))
const { t, locale, locales, setLocale } = useI18n()

// `setLocale` is typed against the configured codes, not plain string: the value comes from `locales`, but the type has to say so.
type LocaleCode = typeof locale.value

const selected = computed({
get: () => locale.value,
set: (value: LocaleCode) => setLocale(value)
})
const current = computed(() => locales.value.find(entry => entry.code === locale.value))

const items = computed<DropdownMenuItem[]>(() => locales.value.map(entry => ({
label: entry.name ?? entry.code,
type: 'checkbox' as const,
checked: entry.code === locale.value,
onSelect: () => setLocale(entry.code as LocaleCode)
})))
</script>

<template>
<USelect
v-model="selected"
:items="items"
value-key="value"
size="sm"
class="w-28"
/>
<!-- A menu rather than a select: the trigger collapses to its icon on a phone, where a select has to keep room for its longest language name. -->
<UDropdownMenu :items="items">
<UButton
variant="ghost"
size="sm"
icon="i-lucide-languages"
:aria-label="t('nav.language')"
>
<span class="hidden sm:inline">{{ current?.name ?? locale }}</span>
</UButton>
</UDropdownMenu>
</template>
8 changes: 4 additions & 4 deletions apps/app/app/components/LegalTemplateDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -165,13 +165,13 @@ function apply() {
min="1"
:disabled="!included[key]"
placeholder="30"
class="w-28"
class="w-24 shrink-0 sm:w-28"
/>
<USelect
v-model="durations[key]!.unit"
:items="unitItems"
:disabled="!included[key]"
class="w-40"
class="min-w-0 flex-1 sm:w-40 sm:flex-none"
/>
</div>

Expand Down Expand Up @@ -213,11 +213,11 @@ function apply() {
</template>

<template #footer>
<div class="flex w-full items-center justify-between gap-4">
<div class="flex w-full flex-col-reverse gap-3 sm:flex-row sm:items-center sm:justify-between">
<p class="text-xs text-muted">
{{ t('admin.legal.template.hint') }}
</p>
<div class="flex shrink-0 gap-2">
<div class="flex shrink-0 justify-end gap-2">
<UButton
color="neutral"
variant="ghost"
Expand Down
4 changes: 2 additions & 2 deletions apps/app/app/components/UnlimitedNumberField.vue
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,13 @@ watch(numberValue, (value) => {

<template>
<UFormField :label="label">
<div class="flex items-center gap-3">
<div class="flex flex-wrap items-center gap-x-3 gap-y-2">
<UInput
v-model.number="numberValue"
type="number"
:min="min ?? 0"
:disabled="unlimited"
class="w-full"
class="min-w-40 flex-1"
/>
<div class="flex shrink-0 items-center gap-1.5">
<USwitch v-model="unlimited" />
Expand Down
25 changes: 13 additions & 12 deletions apps/app/app/pages/account.vue
Original file line number Diff line number Diff line change
Expand Up @@ -232,16 +232,11 @@ async function deleteAccount() {

<template>
<div class="mx-auto max-w-xl p-4">
<div class="mb-6 flex items-center justify-between">
<div class="mb-6 flex items-center justify-between gap-3">
<h1 class="text-xl font-semibold">
{{ t('account.title') }}
</h1>
<UButton
variant="ghost"
icon="i-lucide-arrow-left"
:label="t('dashboard.backToCreate')"
:to="localePath('/')"
/>
<BackButton />
</div>

<UAlert
Expand Down Expand Up @@ -441,7 +436,7 @@ async function deleteAccount() {
<!-- Enrollment: step 1, ask for password -->
<div
v-if="!user?.twoFactorEnabled && twoFactorStep === 'idle'"
class="flex items-end gap-2"
class="flex flex-col gap-2 sm:flex-row sm:items-end"
>
<UFormField
:label="t('account.twoFactor.passwordToEnable')"
Expand All @@ -455,6 +450,8 @@ async function deleteAccount() {
/>
</UFormField>
<UButton
block
class="sm:w-auto"
:loading="twoFactorLoading"
:disabled="!enrollPassword"
:label="t('account.twoFactor.enable')"
Expand All @@ -473,9 +470,9 @@ async function deleteAccount() {
<img
:src="totpQrDataUrl"
:alt="t('account.twoFactor.title')"
class="size-48 rounded-lg border border-default p-2"
class="size-48 max-w-full rounded-lg border border-default p-2"
>
<p class="font-mono text-xs text-muted">
<p class="font-mono text-xs break-all text-muted">
{{ t('account.twoFactor.manualEntry') }}: {{ totpSecret }}
</p>

Expand Down Expand Up @@ -551,7 +548,7 @@ async function deleteAccount() {
/>
</div>

<div class="flex items-end gap-2">
<div class="flex flex-col gap-2 sm:flex-row sm:items-end">
<UFormField
:label="t('account.twoFactor.passwordToRegenerate')"
class="flex-1"
Expand All @@ -564,7 +561,9 @@ async function deleteAccount() {
/>
</UFormField>
<UButton
block
variant="subtle"
class="sm:w-auto"
:loading="regenerating"
:disabled="!regeneratePassword"
:label="t('account.twoFactor.regenerate')"
Expand All @@ -580,7 +579,7 @@ async function deleteAccount() {
</p>
<div
v-else
class="flex items-end gap-2 border-t border-default pt-4"
class="flex flex-col gap-2 border-t border-default pt-4 sm:flex-row sm:items-end"
>
<UFormField
:label="t('account.twoFactor.passwordToDisable')"
Expand All @@ -594,8 +593,10 @@ async function deleteAccount() {
/>
</UFormField>
<UButton
block
color="error"
variant="subtle"
class="sm:w-auto"
:loading="disabling"
:disabled="!disablePassword"
:label="t('account.twoFactor.disable')"
Expand Down
18 changes: 7 additions & 11 deletions apps/app/app/pages/admin/allowed-ips.vue
Original file line number Diff line number Diff line change
Expand Up @@ -69,16 +69,11 @@ function formatDate(value: string) {

<template>
<div class="mx-auto max-w-5xl p-4">
<div class="mb-2 flex items-center justify-between">
<div class="mb-2 flex items-center justify-between gap-3">
<h1 class="text-xl font-semibold">
{{ t('admin.allowedIps.title') }}
</h1>
<UButton
variant="ghost"
icon="i-lucide-arrow-left"
:label="t('dashboard.backToCreate')"
:to="localePath('/')"
/>
<BackButton />
</div>
<AdminNav />

Expand Down Expand Up @@ -153,19 +148,20 @@ function formatDate(value: string) {
v-for="entry in allowedIps"
:key="entry.id"
>
<div class="flex items-center justify-between gap-4">
<div>
<p class="font-mono text-sm">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<p class="font-mono text-sm break-all">
{{ entry.ip }}
</p>
<p class="text-xs text-muted">
<p class="text-xs break-words text-muted">
<span v-if="entry.label">{{ entry.label }} · </span>{{ t('admin.allowedIps.addedAt', { date: formatDate(entry.createdAt) }) }}
</p>
</div>
<UButton
color="error"
variant="ghost"
icon="i-lucide-trash-2"
class="shrink-0"
:loading="removingId === entry.id"
@click="remove(entry.id)"
/>
Expand Down
Loading