Compare commits

...

5 Commits

Author SHA1 Message Date
tonyaellie
c444117a1d Merge branch 'tonya/collections' of https://github.com/sysadminsmedia/homebox into tonya/collections 2025-12-27 11:33:49 +00:00
tonyaellie
397aed47a8 feat: add very mock admin page 2025-12-27 11:28:30 +00:00
tonyaellie
9e8172657b feat: mock ui 2025-12-27 11:28:27 +00:00
tonyaellie
ab57085f8b feat: add very mock admin page 2025-12-27 11:19:09 +00:00
tonyaellie
12d6b17318 feat: mock ui 2025-06-02 12:08:29 +00:00
18 changed files with 1137 additions and 19 deletions

View File

@@ -0,0 +1,130 @@
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button
variant="outline"
role="combobox"
:aria-expanded="open"
class="w-full justify-between"
>
{{ value && value.name ? value.name : "Select inventory" }}
<div class="flex items-center gap-2" v-if="value">
<Badge
class="whitespace-nowrap"
:variant="value.role === 'owner' ? 'default' : value.role === 'admin' ? 'secondary' : 'outline'"
>
{{ value.role }}
</Badge>
</div>
<ChevronsUpDown class="ml-2 size-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent class="w-[--reka-popper-anchor-width] p-0">
<Command :ignore-filter="true">
<CommandInput v-model="search" placeholder="Search collections..." :display-value="(_) => ''" />
<CommandEmpty>No inventory found</CommandEmpty>
<CommandList>
<CommandGroup heading="Your Collections">
<CommandItem
v-for="org in filteredOrgs"
:key="org.id"
:value="org.id"
@select="selectOrg(org as unknown as OrgSummary)"
>
<Check :class="cn('mr-2 h-4 w-4', value?.id === org.id ? 'opacity-100' : 'opacity-0')" />
<div class="flex w-full items-center justify-between gap-2">
{{ org.name }}
<div class="flex items-center gap-2">
<Badge
class="whitespace-nowrap"
variant="outline"
>
{{ org.count }}
</Badge>
<Badge
class="whitespace-nowrap"
:variant="org.role === 'owner' ? 'default' : org.role === 'admin' ? 'secondary' : 'outline'"
>
{{ org.role }}
</Badge>
</div>
</div>
</CommandItem>
</CommandGroup>
<CommandGroup>
<CommandItem @select="() => {}">
<Plus class="mr-2 size-4" /> Create New Collection
</CommandItem>
<CommandItem @select="() => {}">
<Plus class="mr-2 size-4" /> Join Existing Collection
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</template>
<script setup lang="ts">
import { Check, ChevronsUpDown, Lock, Users, Plus } from "lucide-vue-next";
import fuzzysort from "fuzzysort";
import { Button } from "~/components/ui/button";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "~/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover";
import { Badge } from "@/components/ui/badge";
import { cn } from "~/lib/utils";
import { ref, computed, watch } from "vue";
import { useVModel } from "@vueuse/core";
type OrgSummary = {
id: string;
name: string;
count: number;
role: "owner" | "admin" | "editor" | "viewer";
type: "personal" | "org";
};
type Props = {
modelValue?: OrgSummary | null;
};
const props = defineProps<Props>();
const emit = defineEmits(["update:modelValue"]);
const open = ref(false);
const search = ref("");
const value = useVModel(props, "modelValue", emit);
// Mock data for demonstration purposes
const orgs = ref<OrgSummary[]>([
{ id: "1", name: "Personal Inventory", count: 1, role: "owner", type: "personal" },
{ id: "2", name: "Family Home", count: 4, role: "admin", type: "org" },
{ id: "3", name: "Office Equipment", count: 12, role: "editor", type: "org" },
{ id: "4", name: "Workshop Tools", count: 3, role: "viewer", type: "org" },
]);
function selectOrg(org: OrgSummary) {
if (value.value?.id !== org.id) {
value.value = org;
} else {
value.value = null;
}
open.value = false;
}
const filteredOrgs = computed(() => {
const filtered = fuzzysort.go(search.value, orgs.value, { key: "name", all: true }).map((i) => i.obj);
return filtered;
});
// Reset search when value is cleared
watch(
() => value.value,
() => {
if (!value.value) {
search.value = "";
}
},
);
</script>

View File

@@ -1,18 +1,18 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { Primitive, type PrimitiveProps } from "reka-ui";
import { type ButtonVariants, buttonVariants } from ".";
import { cn } from "@/lib/utils";
import type { HTMLAttributes } from "vue";
import { Primitive, type PrimitiveProps } from "reka-ui";
import { type ButtonVariants, buttonVariants } from ".";
import { cn } from "@/lib/utils";
interface Props extends PrimitiveProps {
variant?: ButtonVariants["variant"];
size?: ButtonVariants["size"];
class?: HTMLAttributes["class"];
}
interface Props extends PrimitiveProps {
variant?: ButtonVariants["variant"];
size?: ButtonVariants["size"];
class?: HTMLAttributes["class"];
}
const props = withDefaults(defineProps<Props>(), {
as: "button",
});
const props = withDefaults(defineProps<Props>(), {
as: "button",
});
</script>
<template>

View File

@@ -0,0 +1,57 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { CalendarRoot, type CalendarRootEmits, type CalendarRootProps, useForwardPropsEmits } from 'reka-ui'
import { cn } from '@/lib/utils'
import { CalendarCell, CalendarCellTrigger, CalendarGrid, CalendarGridBody, CalendarGridHead, CalendarGridRow, CalendarHeadCell, CalendarHeader, CalendarHeading, CalendarNextButton, CalendarPrevButton } from '.'
const props = defineProps<CalendarRootProps & { class?: HTMLAttributes['class'] }>()
const emits = defineEmits<CalendarRootEmits>()
const delegatedProps = reactiveOmit(props, 'class')
const forwarded = useForwardPropsEmits(delegatedProps, emits)
</script>
<template>
<CalendarRoot
v-slot="{ grid, weekDays }"
:class="cn('p-3', props.class)"
v-bind="forwarded"
>
<CalendarHeader>
<CalendarPrevButton />
<CalendarHeading />
<CalendarNextButton />
</CalendarHeader>
<div class="flex flex-col gap-y-4 mt-4 sm:flex-row sm:gap-x-4 sm:gap-y-0">
<CalendarGrid v-for="month in grid" :key="month.value.toString()">
<CalendarGridHead>
<CalendarGridRow>
<CalendarHeadCell
v-for="day in weekDays" :key="day"
>
{{ day }}
</CalendarHeadCell>
</CalendarGridRow>
</CalendarGridHead>
<CalendarGridBody>
<CalendarGridRow v-for="(weekDates, index) in month.rows" :key="`weekDate-${index}`" class="mt-2 w-full">
<CalendarCell
v-for="weekDate in weekDates"
:key="weekDate.toString()"
:date="weekDate"
>
<CalendarCellTrigger
:day="weekDate"
:month="month.value"
/>
</CalendarCell>
</CalendarGridRow>
</CalendarGridBody>
</CalendarGrid>
</div>
</CalendarRoot>
</template>

View File

@@ -0,0 +1,21 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { CalendarCell, type CalendarCellProps, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<CalendarCellProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarCell
:class="cn('relative h-9 w-9 p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([data-selected])]:rounded-md [&:has([data-selected])]:bg-accent [&:has([data-selected][data-outside-view])]:bg-accent/50', props.class)"
v-bind="forwardedProps"
>
<slot />
</CalendarCell>
</template>

View File

@@ -0,0 +1,35 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { CalendarCellTrigger, type CalendarCellTriggerProps, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<CalendarCellTriggerProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarCellTrigger
:class="cn(
buttonVariants({ variant: 'ghost' }),
'h-9 w-9 p-0 font-normal',
'[&[data-today]:not([data-selected])]:bg-accent [&[data-today]:not([data-selected])]:text-accent-foreground',
// Selected
'data-[selected]:bg-primary data-[selected]:text-primary-foreground data-[selected]:opacity-100 data-[selected]:hover:bg-primary data-[selected]:hover:text-primary-foreground data-[selected]:focus:bg-primary data-[selected]:focus:text-primary-foreground',
// Disabled
'data-[disabled]:text-muted-foreground data-[disabled]:opacity-50',
// Unavailable
'data-[unavailable]:text-destructive-foreground data-[unavailable]:line-through',
// Outside months
'data-[outside-view]:text-muted-foreground data-[outside-view]:opacity-50 [&[data-outside-view][data-selected]]:bg-accent/50 [&[data-outside-view][data-selected]]:text-muted-foreground [&[data-outside-view][data-selected]]:opacity-30',
props.class,
)"
v-bind="forwardedProps"
>
<slot />
</CalendarCellTrigger>
</template>

View File

@@ -0,0 +1,21 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { CalendarGrid, type CalendarGridProps, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<CalendarGridProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarGrid
:class="cn('w-full border-collapse space-y-1', props.class)"
v-bind="forwardedProps"
>
<slot />
</CalendarGrid>
</template>

View File

@@ -0,0 +1,11 @@
<script lang="ts" setup>
import { CalendarGridBody, type CalendarGridBodyProps } from 'reka-ui'
const props = defineProps<CalendarGridBodyProps>()
</script>
<template>
<CalendarGridBody v-bind="props">
<slot />
</CalendarGridBody>
</template>

View File

@@ -0,0 +1,11 @@
<script lang="ts" setup>
import { CalendarGridHead, type CalendarGridHeadProps } from 'reka-ui'
const props = defineProps<CalendarGridHeadProps>()
</script>
<template>
<CalendarGridHead v-bind="props">
<slot />
</CalendarGridHead>
</template>

View File

@@ -0,0 +1,18 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { CalendarGridRow, type CalendarGridRowProps, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<CalendarGridRowProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarGridRow :class="cn('flex', props.class)" v-bind="forwardedProps">
<slot />
</CalendarGridRow>
</template>

View File

@@ -0,0 +1,18 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { CalendarHeadCell, type CalendarHeadCellProps, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<CalendarHeadCellProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarHeadCell :class="cn('w-9 rounded-md text-[0.8rem] font-normal text-muted-foreground', props.class)" v-bind="forwardedProps">
<slot />
</CalendarHeadCell>
</template>

View File

@@ -0,0 +1,18 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { CalendarHeader, type CalendarHeaderProps, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<CalendarHeaderProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarHeader :class="cn('relative flex w-full items-center justify-between pt-1', props.class)" v-bind="forwardedProps">
<slot />
</CalendarHeader>
</template>

View File

@@ -0,0 +1,28 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { CalendarHeading, type CalendarHeadingProps, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
const props = defineProps<CalendarHeadingProps & { class?: HTMLAttributes['class'] }>()
defineSlots<{
default: (props: { headingValue: string }) => any
}>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarHeading
v-slot="{ headingValue }"
:class="cn('text-sm font-medium', props.class)"
v-bind="forwardedProps"
>
<slot :heading-value>
{{ headingValue }}
</slot>
</CalendarHeading>
</template>

View File

@@ -0,0 +1,29 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ChevronRight } from 'lucide-vue-next'
import { CalendarNext, type CalendarNextProps, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<CalendarNextProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarNext
:class="cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
props.class,
)"
v-bind="forwardedProps"
>
<slot>
<ChevronRight class="h-4 w-4" />
</slot>
</CalendarNext>
</template>

View File

@@ -0,0 +1,29 @@
<script lang="ts" setup>
import type { HTMLAttributes } from 'vue'
import { reactiveOmit } from '@vueuse/core'
import { ChevronLeft } from 'lucide-vue-next'
import { CalendarPrev, type CalendarPrevProps, useForwardProps } from 'reka-ui'
import { cn } from '@/lib/utils'
import { buttonVariants } from '@/components/ui/button'
const props = defineProps<CalendarPrevProps & { class?: HTMLAttributes['class'] }>()
const delegatedProps = reactiveOmit(props, 'class')
const forwardedProps = useForwardProps(delegatedProps)
</script>
<template>
<CalendarPrev
:class="cn(
buttonVariants({ variant: 'outline' }),
'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100',
props.class,
)"
v-bind="forwardedProps"
>
<slot>
<ChevronLeft class="h-4 w-4" />
</slot>
</CalendarPrev>
</template>

View File

@@ -0,0 +1,12 @@
export { default as Calendar } from './Calendar.vue'
export { default as CalendarCell } from './CalendarCell.vue'
export { default as CalendarCellTrigger } from './CalendarCellTrigger.vue'
export { default as CalendarGrid } from './CalendarGrid.vue'
export { default as CalendarGridBody } from './CalendarGridBody.vue'
export { default as CalendarGridHead } from './CalendarGridHead.vue'
export { default as CalendarGridRow } from './CalendarGridRow.vue'
export { default as CalendarHeadCell } from './CalendarHeadCell.vue'
export { default as CalendarHeader } from './CalendarHeader.vue'
export { default as CalendarHeading } from './CalendarHeading.vue'
export { default as CalendarNextButton } from './CalendarNextButton.vue'
export { default as CalendarPrevButton } from './CalendarPrevButton.vue'

View File

@@ -24,6 +24,7 @@
<AppLogo />
</div>
</NuxtLink>
<!-- <AppOrgSelector v-model:model-value="selectedOrg" /> -->
<DropdownMenu>
<DropdownMenuTrigger as-child>
<SidebarMenuButton
@@ -85,6 +86,19 @@
<span>{{ $t("menu.scanner") }}</span>
</SidebarMenuButton>
</SidebarMenuItem>
<SidebarMenuItem>
<SidebarMenuButton
:class="{
'text-nowrap': typeof locale === 'string' && locale.startsWith('zh-'),
}"
:tooltip="$t('menu.scanner')"
@click.prevent="openDialog('scanner')"
>
<MdiAccount />
<span>Collection Settings</span>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroup>
</SidebarContent>
@@ -119,12 +133,24 @@
}"
>
<div class="flex h-1/2 items-center gap-2 sm:h-auto">
<div>
<SidebarTrigger variant="default" />
</div>
<!-- <div>
<Button size="icon">
<AppLogo class="size-8" />
</Button>
</div> -->
<NuxtLink to="/home">
<AppHeaderText class="h-6" />
</NuxtLink>
<!-- <AppOrgSelector v-model:model-value="selectedOrg" /> -->
</div>
<div class="sm:grow" />
<!-- <div class="flex items-center">
<AppOrgSelector v-model:model-value="selectedOrg" />
</div> -->
<div class="flex h-1/2 grow items-center justify-end gap-2 sm:h-auto">
<Input
v-model:model-value="search"
@@ -226,6 +252,8 @@
import AppHeaderDecor from "~/components/App/HeaderDecor.vue";
import AppHeaderText from "~/components/App/HeaderText.vue";
const selectedOrg = ref<any>();
const { t, locale } = useI18n();
const username = computed(() => authCtx.user?.name || "User");
@@ -345,13 +373,13 @@
name: computed(() => t("menu.maintenance")),
to: "/maintenance",
},
{
icon: MdiAccount,
id: 5,
active: computed(() => route.path === "/profile"),
name: computed(() => t("menu.profile")),
to: "/profile",
},
// {
// icon: MdiAccount,
// id: 5,
// active: computed(() => route.path === "/profile"),
// name: computed(() => t("menu.profile")),
// to: "/profile",
// },
{
icon: MdiCog,
id: 6,
@@ -406,6 +434,36 @@
const authCtx = useAuthContext();
const api = useUserApi();
const checkAuth = async () => {
try {
// await api.user.self();
} catch (err) {
console.log(err);
// if (!authCtx.isAuthorized()) {
// console.log("Not authorised, redirecting to login");
// await navigateTo("/");
// }
}
};
onMounted(() => {
checkAuth();
});
onMounted(() => {
const handleVisibilityChange = () => {
if (!document.hidden) {
checkAuth();
}
};
document.addEventListener("visibilitychange", handleVisibilityChange);
onUnmounted(() => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
});
});
async function logout() {
await authCtx.logout(api);
navigateTo("/");

260
frontend/pages/admin.vue Normal file
View File

@@ -0,0 +1,260 @@
<script setup lang="ts">
import { ref, computed } from "vue";
import { useI18n } from "vue-i18n";
import { useConfirm } from "~/composables/use-confirm";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, TableEmpty } from "~/components/ui/table";
import { Button } from "~/components/ui/button";
import MdiPencil from "~icons/mdi/pencil";
import MdiDelete from "~icons/mdi/delete";
import MdiAccountMultiple from "~icons/mdi/account-multiple";
import MdiOpenInNew from "~icons/mdi/open-in-new";
import MdiCheck from "~icons/mdi/check";
type Group = { id: string; name: string; ownerName?: string };
type User = {
id: string;
name: string;
email: string;
role: "admin" | "user" | string;
// password_set indicates whether the user has a local password
password_set?: boolean;
group?: Group | null;
oidc_subject?: string | null;
oidc_issuer?: string | null;
};
// Mock groups (group.name is the owner's name per your request)
const groups = ref<Group[]>([
{ id: "g1", name: "Alice Admin" },
{ id: "g2", name: "Owner Two" },
]);
const users = ref<User[]>([
{
id: "1",
name: "Alice Admin",
email: "alice@example.com",
role: "admin",
password_set: true,
group: groups.value[0],
},
{
id: "2",
name: "Bob User",
email: "bob@example.com",
role: "user",
password_set: true,
group: groups.value[0],
oidc_subject: "bob-sub",
oidc_issuer: "https://oidc.example.com",
},
{ id: "3", name: "Charlie", email: "charlie@example.com", role: "user", password_set: false, group: null },
]);
const query = ref("");
const filtered = computed(() => {
const q = query.value.trim().toLowerCase();
if (!q) return users.value;
return users.value.filter(u => {
return `${u.name} ${u.email} ${u.role}`.toLowerCase().includes(q);
});
});
const editing = ref<User | null>(null);
const showForm = ref(false);
const newPassword = ref("");
const editingGroupId = ref<string | null>(null);
const confirm = useConfirm();
const { t } = useI18n();
const isEditingExisting = computed(() => editing.value !== null && users.value.some(u => u.id === editing.value!.id));
const editingIsAdmin = computed({
get: () => editing.value?.role === "admin",
set: (v: boolean) => {
if (!editing.value) return;
editing.value.role = v ? "admin" : "user";
},
});
// helper to compute auth type for display
// authType removed — not used in the template
function openAdd() {
editing.value = { id: String(Date.now()), name: "", email: "", role: "user", password_set: false, group: null };
newPassword.value = "";
editingGroupId.value = null;
showForm.value = true;
}
function openEdit(u: User) {
editing.value = { ...u };
editingGroupId.value = u.group?.id ?? null;
newPassword.value = "";
showForm.value = true;
}
function saveUser() {
if (!editing.value) return;
// basic validation
if (!editing.value.name.trim() || !editing.value.email.trim()) {
// keep UX simple: alert for now
// Replace with a nicer notification component when available
alert("Name and email are required");
return;
}
const idx = users.value.findIndex(x => x.id === editing.value!.id);
if (idx >= 0) {
// apply password flag if new password was set locally
if (newPassword.value && editing.value) editing.value.password_set = true;
// apply group selection object
if (editing.value) {
editing.value.group = groups.value.find(g => g.id === editingGroupId.value) ?? null;
}
users.value.splice(idx, 1, { ...editing.value });
} else {
if (newPassword.value && editing.value) editing.value.password_set = true;
if (editing.value) editing.value.group = groups.value.find(g => g.id === editingGroupId.value) ?? null;
users.value.unshift({ ...editing.value });
}
editing.value = null;
showForm.value = false;
// TODO: call backend API to persist changes when available
}
function cancelForm() {
editing.value = null;
showForm.value = false;
}
async function confirmDelete(u: User) {
const { isCanceled } = await confirm.open({
message: t("global.delete_confirm") + " " + `${u.name} (${u.email})?`,
});
if (isCanceled) return;
users.value = users.value.filter(x => x.id !== u.id);
// TODO: call backend API to delete user when available
}
// no more toggleActive; active is not used
</script>
<template>
<div class="mx-auto max-w-6xl p-6">
<header class="mb-6 flex items-center justify-between">
<h1 class="text-2xl font-semibold">{{ t("global.details") }} - Administration</h1>
<div class="flex items-center gap-3">
<input v-model="query" :placeholder="t('global.search')" class="rounded border px-3 py-2" />
<Button @click="openAdd">{{ t("global.add") }}</Button>
</div>
</header>
<section>
<Table class="w-full">
<TableHeader>
<TableRow>
<TableHead>{{ t("global.name") }}</TableHead>
<TableHead>{{ t("global.email") }}</TableHead>
<TableHead>Role</TableHead>
<TableHead>Group</TableHead>
<TableHead class="w-32 text-center">Auth</TableHead>
<TableHead class="w-40 text-center">{{ t("global.details") }}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<template v-if="filtered.length">
<TableRow v-for="u in filtered" :key="u.id">
<TableCell>{{ u.name }}</TableCell>
<TableCell>{{ u.email }}</TableCell>
<TableCell class="flex items-center gap-2">
<MdiCheck v-if="u.role === 'admin'" class="size-4 text-green-600" />
<span v-if="u.role === 'admin'">admin</span>
<span v-else>-</span>
</TableCell>
<TableCell>
<div class="flex items-center gap-2">
<MdiAccountMultiple class="size-4" />
<span>{{ u.group?.name ?? "-" }}</span>
</div>
</TableCell>
<TableCell class="text-center">
<span v-if="u.oidc_subject" :title="u.oidc_issuer || u.oidc_subject">
<MdiOpenInNew class="inline-block size-4" />
</span>
</TableCell>
<TableCell class="text-right">
<div class="flex justify-end gap-2">
<Button size="icon" variant="outline" class="size-8" :title="t('global.edit')" @click="openEdit(u)">
<MdiPencil class="size-4" />
</Button>
<Button
size="icon"
variant="destructive"
class="size-8"
:title="t('global.delete')"
@click="confirmDelete(u)"
>
<MdiDelete class="size-4" />
</Button>
</div>
</TableCell>
</TableRow>
</template>
<template v-else>
<TableEmpty :colspan="6">
<p>{{ $t("items.selector.no_results") }}</p>
</TableEmpty>
</template>
</TableBody>
</Table>
</section>
<!-- Add / Edit form modal (simple) -->
<div v-if="showForm" class="fixed inset-0 z-40 flex items-center justify-center bg-black/40">
<div class="w-full max-w-md rounded bg-white p-6 shadow-lg">
<h2 class="mb-4 text-lg font-medium">{{ isEditingExisting ? t("global.edit") : t("global.add") }}</h2>
<div class="space-y-3">
<label class="block">
<div class="mb-1 text-sm">{{ t("global.name") }}</div>
<input v-model="editing!.name" class="w-full rounded border px-3 py-2" />
</label>
<label class="block">
<div class="mb-1 text-sm">{{ t("global.email") }}</div>
<input v-model="editing!.email" class="w-full rounded border px-3 py-2" />
</label>
<label class="flex items-center gap-2">
<input v-model="editingIsAdmin" type="checkbox" />
<span class="text-sm">Admin</span>
</label>
<label class="block">
<div class="mb-1 text-sm">Password</div>
<input
v-model="newPassword"
type="password"
placeholder="Leave blank to keep"
class="w-full rounded border px-3 py-2"
/>
</label>
<label class="block">
<div class="mb-1 text-sm">Group</div>
<select v-model="editingGroupId" class="w-full rounded border px-3 py-2">
<option :value="null">-</option>
<option v-for="g in groups" :key="g.id" :value="g.id">{{ g.name }}</option>
</select>
</label>
</div>
<div class="mt-4 flex justify-end gap-2">
<Button variant="outline" @click="cancelForm">{{ t("global.cancel") }}</Button>
<Button @click="saveUser">{{ t("global.save") }}</Button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,362 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { v4 as uuidv4 } from 'uuid'; // For generating unique invite IDs
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Card } from '@/components/ui/card'; // Assuming you have a Card component
import { Badge } from '@/components/ui/badge'; // Assuming you have a Badge component
import {
Calendar as CalendarIcon,
PlusCircle,
Trash,
} from 'lucide-vue-next'; // Icons
import { Calendar } from '@/components/ui/calendar';
import { format } from 'date-fns';
const { t } = useI18n();
definePageMeta({
middleware: ['auth'],
});
useHead({
title: 'HomeBox | ' + t('menu.maintenance'),
});
interface User {
username: string;
id: string;
role: 'owner' | 'admin' | 'editor' | 'viewer';
lastActive: string;
added: string;
}
interface Invite {
id: string;
code: string;
expiresAt: Date | null;
maxUses: number | null;
uses: number;
}
const users = ref<User[]>([
{
username: 'tonya',
id: '1',
role: 'owner',
lastActive: '12 hours ago',
added: '13 hours ago',
},
{
username: 'steve',
id: '2',
role: 'admin',
lastActive: '1 day ago',
added: '2 days ago',
},
{
username: 'bob',
id: '3',
role: 'editor',
lastActive: '30 minutes ago',
added: '5 hours ago',
},
{
username: 'john',
id: '4',
role: 'viewer',
lastActive: '2 hours ago',
added: '1 day ago',
},
]);
const invites = ref<Invite[]>([
{
id: uuidv4(),
code: 'ABCDEF',
expiresAt: null,
maxUses: null,
uses: 0,
},
{
id: uuidv4(),
code: 'GHIJKL',
expiresAt: new Date(new Date().setDate(new Date().getDate() + 7)), // Expires in 7 days
maxUses: 5,
uses: 2,
},
]);
const newInviteExpiresAt = ref<Date | null>(null);
const newInviteMaxUses = ref<number | null>(null);
const page = ref(1);
const roles = ['owner', 'admin', 'editor', 'viewer'];
function handleRoleChange(userId: string, newRole: string) {
const userIndex = users.value.findIndex((user) => user.id === userId);
if (userIndex !== -1) {
users.value[userIndex].role = newRole as
| 'owner'
| 'admin'
| 'editor'
| 'viewer';
}
}
function handleRemoveUser(userId: string) {
users.value = users.value.filter((user) => user.id !== userId);
}
function generateInviteCode() {
return Math.random().toString(36).substring(2, 8).toUpperCase();
}
function createNewInvite() {
const newInvite: Invite = {
id: uuidv4(),
code: generateInviteCode(),
expiresAt: newInviteExpiresAt.value,
maxUses: newInviteMaxUses.value,
uses: 0,
};
invites.value.push(newInvite);
newInviteExpiresAt.value = null;
newInviteMaxUses.value = null;
}
function deleteInvite(inviteId: string) {
invites.value = invites.value.filter((invite) => invite.id !== inviteId);
}
</script>
<template>
<div>
<BaseContainer class="flex flex-col gap-4">
<BaseSectionHeader> Collection Settings </BaseSectionHeader>
<ButtonGroup>
<Button
size="sm"
:variant="page == 1 ? 'default' : 'outline'"
@click="page = 1"
>
Users
</Button>
<Button
size="sm"
:variant="page == 2 ? 'default' : 'outline'"
@click="page = 2"
>
Invites
</Button>
<Button
size="sm"
:variant="page == 3 ? 'default' : 'outline'"
@click="page = 3"
>
Settings
</Button>
</ButtonGroup>
<Card v-if="page == 1" class="p-4 m-4">
<Table>
<TableHeader>
<TableRow>
<TableHead>Username</TableHead>
<TableHead>Role</TableHead>
<TableHead>Last Active</TableHead>
<TableHead>Added</TableHead>
<TableHead class="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="user in users" :key="user.id">
<TableCell class="font-medium">
{{ user.username }}
</TableCell>
<TableCell>
<Badge
:variant="
user.role === 'owner'
? 'default'
: user.role === 'admin'
? 'secondary'
: 'outline'
"
>
{{ user.role }}
</Badge>
</TableCell>
<TableCell>{{ user.lastActive }}</TableCell>
<TableCell>{{ user.added }}</TableCell>
<TableCell class="text-right">
<Popover>
<PopoverTrigger as-child>
<Button size="sm" variant="outline"> Edit </Button>
</PopoverTrigger>
<PopoverContent class="w-48">
<div class="grid gap-4">
<div class="space-y-2">
<h4 class="font-medium leading-none">Edit User</h4>
<p class="text-sm text-muted-foreground">
{{ user.username }}
</p>
</div>
<div class="grid gap-2">
<Label for="role">Role</Label>
<Select
:model-value="user.role"
@update:model-value="
(newRole) => handleRoleChange(user.id, newRole)
"
>
<SelectTrigger>
<SelectValue placeholder="Select a role" />
</SelectTrigger>
<SelectContent>
<SelectItem
v-for="role in roles"
:key="role"
:value="role"
>
{{ role }}
</SelectItem>
</SelectContent>
</Select>
</div>
<Button
variant="destructive"
size="sm"
@click="handleRemoveUser(user.id)"
>
Remove User
</Button>
</div>
</PopoverContent>
</Popover>
</TableCell>
</TableRow>
</TableBody>
</Table>
</Card>
<Card v-if="page == 2" class="p-4 m-4">
<div class="flex flex-col gap-4">
<h3 class="text-lg font-semibold">Existing Invites</h3>
<Table>
<TableHeader>
<TableRow>
<TableHead>Code</TableHead>
<TableHead>Expires</TableHead>
<TableHead>Max Uses</TableHead>
<TableHead>Uses</TableHead>
<TableHead class="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow v-for="invite in invites" :key="invite.id">
<TableCell class="font-medium">
{{ invite.code }}
</TableCell>
<TableCell>
{{
invite.expiresAt
? format(invite.expiresAt, 'PPP')
: 'Never'
}}
</TableCell>
<TableCell>
{{ invite.maxUses !== null ? invite.maxUses : 'Unlimited' }}
</TableCell>
<TableCell>{{ invite.uses }}</TableCell>
<TableCell class="text-right">
<Button
variant="destructive"
size="icon"
@click="deleteInvite(invite.id)"
>
<Trash class="w-4 h-4" />
</Button>
</TableCell>
</TableRow>
</TableBody>
</Table>
<hr class="my-4" />
<h3 class="text-lg font-semibold">Create New Invite</h3>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<div class="flex flex-col gap-2">
<Label for="new-invite-max-uses">Max Uses (optional)</Label>
<Input
id="new-invite-max-uses"
type="number"
v-model.number="newInviteMaxUses"
placeholder="Unlimited"
/>
</div>
<div class="flex flex-col gap-2">
<Label for="new-invite-expires-at">Expires At (optional)</Label>
<Popover>
<PopoverTrigger as-child>
<Button
variant="outline"
class="w-full justify-start text-left font-normal"
:class="
!newInviteExpiresAt && 'text-muted-foreground'
"
>
<CalendarIcon class="mr-2 h-4 w-4" />
{{
newInviteExpiresAt
? format(newInviteExpiresAt, 'PPP')
: 'Pick a date'
}}
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0">
<Calendar v-model:model-value="newInviteExpiresAt" />
</PopoverContent>
</Popover>
</div>
<div class="flex items-end">
<Button @click="createNewInvite" class="w-full">
<PlusCircle class="mr-2 w-4 h-4" /> Generate Invite
</Button>
</div>
</div>
</div>
</Card>
<Card v-if="page == 3" class="p-4 m-4">
<h3 class="text-lg font-semibold">Collection Settings</h3>
<p class="text-muted-foreground">
This is where you would configure general collection settings.
</p>
<!-- Add your settings forms/components here -->
</Card>
</BaseContainer>
</div>
</template>