Files
homebox/frontend/pages/profile.vue
Tonya d4e28e6f3b Upgrade frontend deps, including nuxt (#982)
* feat: begin upgrading deps, still very buggy

* feat: progress

* feat: sort all type issues

* fix: sort type issues

* fix: import sonner styles

* fix: nuxt is the enemy

* fix: try sorting issue with workflows

* fix: update vitest config for dynamic import of path and defineConfig

* fix: add missing import

* fix: add time out to try and fix issues

* fix: add ui:ci:preview task for frontend build in CI mode

* fix: i was silly

* feat: add go:ci:with-frontend task for CI mode and remove ui:ci:preview from e2e workflow

* fix: update baseURL in Playwright config for local testing to use port 7745

* fix: update E2E_BASE_URL and remove wait for timeout in login test for smoother execution
2025-09-04 09:00:25 +01:00

550 lines
17 KiB
Vue

<script setup lang="ts">
import { useI18n } from "vue-i18n";
import { toast } from "@/components/ui/sonner";
import type { Detail } from "~~/components/global/DetailsSection/types";
import type { CurrenciesCurrency, NotifierCreate, NotifierOut } from "~~/lib/api/types/data-contracts";
import MdiLoading from "~icons/mdi/loading";
import MdiAccount from "~icons/mdi/account";
import MdiMegaphone from "~icons/mdi/megaphone";
import MdiDelete from "~icons/mdi/delete";
import MdiFill from "~icons/mdi/fill";
import MdiPencil from "~icons/mdi/pencil";
import MdiAccountMultiple from "~icons/mdi/account-multiple";
import { getLocaleCode } from "~/composables/use-formatters";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { useDialog } from "@/components/ui/dialog-provider";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { badgeVariants } from "@/components/ui/badge";
import LanguageSelector from "~/components/App/LanguageSelector.vue";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { DialogID } from "~/components/ui/dialog-provider/utils";
import ThemePicker from "~/components/App/ThemePicker.vue";
import ItemDuplicateSettings from "~/components/Item/DuplicateSettings.vue";
import FormPassword from "~/components/Form/Password.vue";
import FormCheckbox from "~/components/Form/Checkbox.vue";
import FormTextField from "~/components/Form/TextField.vue";
import BaseContainer from "@/components/Base/Container.vue";
import BaseCard from "@/components/Base/Card.vue";
import BaseSectionHeader from "@/components/Base/SectionHeader.vue";
import DetailsSection from "@/components/global/DetailsSection/DetailsSection.vue";
import CopyText from "@/components/global/CopyText.vue";
import DateTime from "@/components/global/DateTime.vue";
const { t } = useI18n();
definePageMeta({
middleware: ["auth"],
});
useHead({
title: "HomeBox | " + t("menu.profile"),
});
const api = useUserApi();
const confirm = useConfirm();
const { openDialog, closeDialog } = useDialog();
const currencies = computedAsync(async () => {
const resp = await api.group.currencies();
if (resp.error) {
toast.error(t("profile.toast.failed_get_currencies"));
return [];
}
return resp.data;
});
const preferences = useViewPreferences();
function setDisplayHeader() {
preferences.value.displayLegacyHeader = !preferences.value.displayLegacyHeader;
}
// Currency Selection
const currency = ref<CurrenciesCurrency>({
code: "USD",
name: "United States Dollar",
local: "en-US",
symbol: "$",
});
watch(currency, () => {
if (group.value) {
group.value.currency = currency.value.code;
}
});
const currencyExample = computed(() => {
return fmtCurrency(1000, currency.value?.code ?? "USD", getLocaleCode());
});
const { data: group } = useAsyncData(async () => {
const { data } = await api.group.get();
return data;
});
// Sync Initial Currency
watch(group, () => {
if (!group.value) {
return;
}
// @ts-expect-error - typescript is stupid, it should know group.value is not null
const found = currencies.value.find(c => c.code === group.value.currency);
if (found) {
currency.value = found;
}
});
async function updateGroup() {
if (!group.value) {
return;
}
const { data, error } = await api.group.update({
name: group.value.name,
currency: group.value.currency,
});
if (error) {
toast.error(t("profile.toast.failed_update_group"));
return;
}
group.value = data;
toast.success(t("profile.toast.group_updated"));
}
const auth = useAuthContext();
const details = computed(() => {
console.log(auth.user);
return [
{
name: "global.name",
text: auth.user?.name || t("global.unknown"),
},
{
name: "global.email",
text: auth.user?.email || t("global.unknown"),
},
] as Detail[];
});
async function deleteProfile() {
const result = await confirm.open(t("profile.delete_account_confirm"));
if (result.isCanceled) {
return;
}
const { response } = await api.user.delete();
if (response?.status === 204) {
toast.success(t("profile.toast.account_deleted"));
auth.logout(api);
navigateTo("/");
}
toast.error(t("profile.toast.failed_delete_account"));
}
const token = ref("");
const tokenUrl = computed(() => {
if (!window) {
return "";
}
return `${window.location.origin}?token=${token.value}`;
});
async function generateToken() {
const date = new Date();
const { response, data } = await api.group.createInvitation({
expiresAt: new Date(date.setDate(date.getDate() + 7)),
uses: 1,
});
if (response?.status === 201) {
token.value = data.token;
}
}
const passwordChange = reactive({
loading: false,
current: "",
new: "",
isValid: false,
});
async function changePassword() {
passwordChange.loading = true;
if (!passwordChange.isValid) {
return;
}
const { error } = await api.user.changePassword(passwordChange.current, passwordChange.new);
if (error) {
toast.error(t("profile.toast.failed_change_password"));
passwordChange.loading = false;
return;
}
toast.success(t("profile.toast.password_changed"));
closeDialog(DialogID.ChangePassword);
passwordChange.new = "";
passwordChange.current = "";
passwordChange.loading = false;
}
// ===========================================================
// Notifiers
const notifiers = useAsyncData(async () => {
const { data } = await api.notifiers.getAll();
return data;
});
const targetID = ref("");
const notifier = ref<NotifierCreate | null>(null);
function openNotifierDialog(v: NotifierOut | null) {
if (v) {
targetID.value = v.id;
notifier.value = {
name: v.name,
url: v.url,
isActive: v.isActive,
};
} else {
notifier.value = {
name: "",
url: "",
isActive: true,
};
}
openDialog(DialogID.CreateNotifier);
}
async function createNotifier() {
if (!notifier.value) {
return;
}
if (targetID.value) {
await editNotifier();
return;
}
const result = await api.notifiers.create({
name: notifier.value.name,
url: notifier.value.url || "",
isActive: notifier.value.isActive,
});
if (result.error) {
toast.error(t("profile.toast.failed_create_notifier"));
}
notifier.value = null;
closeDialog(DialogID.CreateNotifier);
await notifiers.refresh();
}
async function editNotifier() {
if (!notifier.value) {
return;
}
const result = await api.notifiers.update(targetID.value, {
name: notifier.value.name,
url: notifier.value.url || "",
isActive: notifier.value.isActive,
});
if (result.error) {
toast.error(t("profile.toast.failed_update_notifier"));
}
notifier.value = null;
closeDialog(DialogID.CreateNotifier);
targetID.value = "";
await notifiers.refresh();
}
async function deleteNotifier(id: string) {
const result = await confirm.open(t("profile.delete_notifier_confirm"));
if (result.isCanceled) {
return;
}
const { error } = await api.notifiers.delete(id);
if (error) {
toast.error(t("profile.toast.failed_delete_notifier"));
return;
}
await notifiers.refresh();
}
async function testNotifier() {
if (!notifier.value) {
return;
}
const { error } = await api.notifiers.test(notifier.value.url);
if (error) {
toast.error(t("profile.toast.failed_test_notifier"));
return;
}
toast.success(t("profile.toast.notifier_test_success"));
}
</script>
<template>
<div>
<Dialog :dialog-id="DialogID.DuplicateSettings">
<DialogContent>
<DialogHeader>
<DialogTitle>{{ $t("items.duplicate.title") }}</DialogTitle>
</DialogHeader>
<ItemDuplicateSettings v-model="preferences.duplicateSettings" />
<p class="text-sm text-muted-foreground">
{{ $t("items.duplicate.override_instructions") }}
</p>
</DialogContent>
</Dialog>
<Dialog :dialog-id="DialogID.ChangePassword">
<DialogContent>
<DialogHeader>
<DialogTitle> {{ $t("profile.change_password") }} </DialogTitle>
</DialogHeader>
<FormPassword
v-model="passwordChange.current"
:label="$t('profile.current_password')"
placeholder=""
class="mb-2"
/>
<FormPassword v-model="passwordChange.new" :label="$t('profile.new_password')" placeholder="" />
<PasswordScore v-model:valid="passwordChange.isValid" :password="passwordChange.new" />
<form @submit.prevent="changePassword">
<DialogFooter>
<Button :disabled="!passwordChange.isValid || passwordChange.loading" type="submit">
<MdiLoading v-if="passwordChange.loading" class="animate-spin" />
{{ $t("global.submit") }}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog :dialog-id="DialogID.CreateNotifier">
<DialogContent>
<DialogHeader>
<DialogTitle> {{ $t("profile.notifier_modal", { type: notifier != null }) }} </DialogTitle>
</DialogHeader>
<form @submit.prevent="createNotifier">
<template v-if="notifier">
<FormTextField v-model="notifier.name" :label="$t('global.name')" class="mb-2" />
<FormTextField v-model="notifier.url" :label="$t('profile.url')" class="mb-2" />
<div class="max-w-[100px]">
<FormCheckbox v-model="notifier.isActive" :label="$t('profile.enabled')" />
</div>
</template>
<div class="mt-4 flex justify-between gap-2">
<DialogFooter class="flex w-full">
<Button variant="secondary" :disabled="!(notifier && notifier.url)" type="button" @click="testNotifier">
{{ $t("profile.test") }}
</Button>
<div class="grow" />
<Button type="submit"> {{ $t("global.submit") }} </Button>
</DialogFooter>
</div>
</form>
</DialogContent>
</Dialog>
<BaseContainer class="flex flex-col gap-4">
<BaseCard>
<template #title>
<BaseSectionHeader>
<MdiAccount class="-mt-1 mr-2" />
<span> {{ $t("profile.user_profile") }} </span>
<template #description> {{ $t("profile.user_profile_sub") }} </template>
</BaseSectionHeader>
</template>
<DetailsSection :details="details" />
<div class="p-4">
<div class="flex gap-2">
<Button variant="secondary" size="sm" @click="openDialog(DialogID.ChangePassword)">
{{ $t("profile.change_password") }}
</Button>
<Button variant="secondary" size="sm" @click="generateToken"> {{ $t("profile.gen_invite") }} </Button>
<Button variant="secondary" size="sm" @click="openDialog(DialogID.DuplicateSettings)">
{{ $t("items.duplicate.title") }}
</Button>
</div>
<div v-if="token" class="flex items-center gap-2 pl-1 pt-4">
<CopyText :text="tokenUrl" />
{{ tokenUrl }}
</div>
<div v-if="token" class="flex items-center gap-2 pl-1 pt-4">
<CopyText :text="token" />
{{ token }}
</div>
</div>
<LanguageSelector />
</BaseCard>
<BaseCard>
<template #title>
<BaseSectionHeader>
<MdiMegaphone class="-mt-1 mr-2" />
<span> {{ $t("profile.notifiers") }} </span>
<template #description> {{ $t("profile.notifiers_sub") }} </template>
</BaseSectionHeader>
</template>
<div v-if="notifiers.data.value" class="mx-4 divide-y rounded-md border">
<p v-if="notifiers.data.value.length === 0" class="p-2 text-center text-sm">
{{ $t("profile.no_notifiers") }}
</p>
<article v-for="n in notifiers.data.value" v-else :key="n.id" class="p-2">
<div class="flex flex-wrap items-center gap-2">
<p class="mr-auto text-lg">{{ n.name }}</p>
<TooltipProvider :delay-duration="0" class="flex justify-end gap-2">
<Tooltip>
<TooltipTrigger>
<Button variant="destructive" size="icon" @click="deleteNotifier(n.id)">
<MdiDelete />
</Button>
</TooltipTrigger>
<TooltipContent> {{ $t("global.delete") }} </TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger>
<Button variant="outline" size="icon" @click="openNotifierDialog(n)">
<MdiPencil />
</Button>
</TooltipTrigger>
<TooltipContent> {{ $t("global.edit") }} </TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div class="flex flex-wrap justify-between py-1 text-sm">
<p>
<span v-if="n.isActive" :class="badgeVariants()"> {{ $t("profile.active") }} </span>
<span v-else :class="badgeVariants({ variant: 'destructive' })"> {{ $t("profile.inactive") }} </span>
</p>
<p>
{{ $t("global.created") }}
<DateTime format="relative" datetime-type="time" :date="n.createdAt" />
</p>
</div>
</article>
</div>
<div class="p-4">
<Button variant="secondary" size="sm" @click="openNotifierDialog"> {{ $t("global.create") }} </Button>
</div>
</BaseCard>
<BaseCard>
<template #title>
<BaseSectionHeader class="pb-0">
<MdiAccountMultiple class="-mt-1 mr-2" />
<span> {{ $t("profile.group_settings") }} </span>
<template #description>
{{ $t("profile.group_settings_sub") }}
</template>
</BaseSectionHeader>
</template>
<div v-if="group && currencies && currencies.length > 0" class="p-5 pt-0">
<Label for="currency"> {{ $t("profile.currency_format") }} </Label>
<Select
id="currency"
:model-value="currency.code"
@update:model-value="
event => {
const newCurrency = currencies?.find(c => c.code === event);
if (newCurrency) {
currency = newCurrency;
}
}
"
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem v-for="c in currencies" :key="c.code" :value="c.code">
{{ c.name }}
</SelectItem>
</SelectContent>
</Select>
<p class="m-2 text-sm">{{ $t("profile.example") }}: {{ currencyExample }}</p>
<div class="mt-4">
<Button variant="secondary" size="sm" @click="updateGroup"> {{ $t("profile.update_group") }} </Button>
</div>
</div>
</BaseCard>
<BaseCard>
<template #title>
<BaseSectionHeader>
<MdiFill class="mr-2" />
<span> {{ $t("profile.theme_settings") }} </span>
<template #description>
{{ $t("profile.theme_settings_sub") }}
</template>
</BaseSectionHeader>
</template>
<div class="px-4 pb-4">
<div class="mb-3">
<Button variant="secondary" size="sm" @click="setDisplayHeader">
{{ $t("profile.display_legacy_header", { currentValue: preferences.displayLegacyHeader }) }}
</Button>
</div>
<ThemePicker />
</div>
</BaseCard>
<BaseCard>
<template #title>
<BaseSectionHeader>
<MdiDelete class="-mt-1 mr-2" />
<span> {{ $t("profile.delete_account") }} </span>
<template #description> {{ $t("profile.delete_account_sub") }} </template>
</BaseSectionHeader>
</template>
<div class="border-t-2 p-4 px-6">
<Button size="sm" variant="destructive" @click="deleteProfile">
{{ $t("profile.delete_account") }}
</Button>
</div>
</BaseCard>
</BaseContainer>
</div>
</template>
<style scoped></style>