mirror of
https://github.com/versia-pub/frontend.git
synced 2026-03-13 03:29:16 +01:00
feat: ✨ Add settings page to configure account and preferences
This commit is contained in:
parent
633ff184e3
commit
1691daa000
21 changed files with 687 additions and 183 deletions
88
components/settings/oidc.vue
Normal file
88
components/settings/oidc.vue
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
<template>
|
||||
<!-- OIDC linked accounts manager -->
|
||||
<div class="w-full ring-1 ring-white/5 pb-5 bg-dark-800 rounded overflow-hidden">
|
||||
<div class="px-4 py-4">
|
||||
<h3 class="font-semibold text-gray-300 text-xl">Linked accounts</h3>
|
||||
</div>
|
||||
<div class="px-4 py-3">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
<div v-for="provider of ssoConfig?.providers" :key="provider.id"
|
||||
class="flex items-center justify-between p-4 bg-dark-700 rounded">
|
||||
<div class="flex items-center gap-4">
|
||||
<AvatarsCentered :src="provider.icon" :alt="provider.name" class="h-8 w-8" />
|
||||
<span class="font-semibold text-gray-300">{{ provider.name }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<ButtonsPrimary :loading="loading" v-if="!linkedProviders?.find(p => p.id === provider.id)"
|
||||
@click="link(provider.id)">
|
||||
<span>Link</span>
|
||||
</ButtonsPrimary>
|
||||
<ButtonsSecondary :loading="loading" v-else @click="unlink(provider.id)">
|
||||
<span>Unlink</span>
|
||||
</ButtonsSecondary>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { ResponseError } from "@lysand-org/client";
|
||||
|
||||
const client = useClient();
|
||||
const ssoConfig = useSSOConfig();
|
||||
const linkedProviders = useLinkedSSO(client);
|
||||
const loading = ref(false);
|
||||
|
||||
const link = async (providerId: string) => {
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const output = await client.value.post<{
|
||||
link: string;
|
||||
}>("/api/v1/sso", {
|
||||
issuer: providerId,
|
||||
});
|
||||
|
||||
window.location.href = output.data.link;
|
||||
} catch (error) {
|
||||
const e = error as ResponseError<{ error: string }>;
|
||||
|
||||
useEvent("notification:new", {
|
||||
title: "Failed to link account",
|
||||
message: e.response.data.error,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
const unlink = async (providerId: string) => {
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
await client.value.delete<void>(`/api/v1/sso/${providerId}`);
|
||||
|
||||
useEvent("notification:new", {
|
||||
title: "Account unlinked",
|
||||
type: "success",
|
||||
});
|
||||
|
||||
linkedProviders.value = linkedProviders.value.filter(
|
||||
(p) => p.id !== providerId,
|
||||
);
|
||||
} catch (error) {
|
||||
const e = error as ResponseError<{ error: string }>;
|
||||
|
||||
useEvent("notification:new", {
|
||||
title: "Failed to unlink account",
|
||||
message: e.response.data.error,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
};
|
||||
</script>
|
||||
120
components/settings/profile-editor.vue
Normal file
120
components/settings/profile-editor.vue
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
<template>
|
||||
<div
|
||||
class="w-full md:px-8 px-4 py-4 bg-dark-700 grid justify-center lg:grid-cols-[minmax(auto,_36rem)_1fr] grid-cols-1 gap-4">
|
||||
<form class="w-full ring-1 ring-inset ring-white/5 pb-5 bg-dark-800 rounded overflow-hidden"
|
||||
@submit.prevent="save">
|
||||
<AvatarsCentered :src="account?.header" :alt="`${account?.acct}'s header image'`"
|
||||
class="w-full aspect-[8/3] border-b border-white/10 bg-dark-700 !rounded-none" />
|
||||
|
||||
<div class="flex items-start justify-between px-4 py-3">
|
||||
<AvatarsCentered :src="account?.avatar" :alt="`${account?.acct}'s avatar'`"
|
||||
class="h-32 w-32 -mt-[4.5rem] z-10 shrink-0 rounded ring-2 ring-dark-800" />
|
||||
</div>
|
||||
|
||||
<div class="mt-2 px-4">
|
||||
<InputsText @input="displayName = ($event.target as HTMLInputElement).value" :value="displayName"
|
||||
aria-label="Display name" :disabled="loading" />
|
||||
<div class="mt-2 grid grid-cols-[auto_1fr] items-center gap-x-2">
|
||||
<iconify-icon icon="tabler:at" width="none" class="size-6" aria-hidden="true" />
|
||||
<InputsText @input="acct = ($event.target as HTMLInputElement).value" :value="acct"
|
||||
aria-label="Username" :disabled="loading" />
|
||||
</div>
|
||||
<p class="text-gray-300 text-xs mt-2">
|
||||
Changing your username will break all links to your profile.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 px-4">
|
||||
<InputsRichTextbox v-model:model-content="note" :max-characters="noteCharacterLimit" :disabled="loading"
|
||||
class="rounded ring-white/10 ring-2 focus:ring-primary-600 px-4 py-2 max-h-[40dvh] max-w-full" />
|
||||
</div>
|
||||
|
||||
<div class="px-4 mt-4 grid grid-cols-2 gap-2">
|
||||
<ButtonsPrimary class="w-full" type="submit" :loading="loading">
|
||||
<span>Save</span>
|
||||
</ButtonsPrimary>
|
||||
<ButtonsSecondary class="w-full" @click="revert" type="button" :loading="loading">
|
||||
<span>Revert</span>
|
||||
</ButtonsSecondary>
|
||||
</div>
|
||||
</form>
|
||||
<div>
|
||||
<SettingsOidc />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import type { ResponseError } from "@lysand-org/client";
|
||||
|
||||
const identity = useCurrentIdentity();
|
||||
const account = computed(() => identity.value?.account);
|
||||
const note = ref(account.value?.source?.note ?? "");
|
||||
const displayName = ref(account.value?.display_name ?? "");
|
||||
const acct = ref(account.value?.acct ?? "");
|
||||
const noteCharacterLimit = computed(
|
||||
() => identity.value?.instance.configuration.statuses.max_characters ?? 0,
|
||||
);
|
||||
|
||||
const client = useClient();
|
||||
const loading = ref(false);
|
||||
|
||||
const revert = () => {
|
||||
useEvent("notification:new", {
|
||||
title: "Reverted to current bio",
|
||||
type: "success",
|
||||
});
|
||||
|
||||
note.value = account.value?.source?.note ?? "";
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const changedData = {
|
||||
display_name:
|
||||
displayName.value === account.value?.display_name
|
||||
? undefined
|
||||
: displayName.value,
|
||||
username: acct.value === account.value?.acct ? undefined : acct.value,
|
||||
note:
|
||||
note.value === account.value?.source?.note ? undefined : note.value,
|
||||
};
|
||||
|
||||
if (
|
||||
Object.values(changedData).filter((v) => v !== undefined).length === 0
|
||||
) {
|
||||
useEvent("notification:new", {
|
||||
title: "No changes",
|
||||
type: "error",
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
const { data } = await client.value.updateCredentials(
|
||||
Object.fromEntries(
|
||||
Object.entries(changedData).filter(([, v]) => v !== undefined),
|
||||
),
|
||||
);
|
||||
|
||||
useEvent("notification:new", {
|
||||
title: "Profile updated",
|
||||
type: "success",
|
||||
});
|
||||
|
||||
if (identity.value) identity.value.account = data;
|
||||
} catch (e) {
|
||||
const error = e as ResponseError<{ error: string }>;
|
||||
|
||||
useEvent("notification:new", {
|
||||
title: "Failed to update profile",
|
||||
message: error.response.data.error,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
};
|
||||
</script>
|
||||
47
components/settings/renderer.vue
Normal file
47
components/settings/renderer.vue
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<template>
|
||||
<div class="w-full px-8 py-4 bg-dark-700 hover:bg-dark-500 duration-100">
|
||||
<Switch.Root v-model:checked="checked" class="grid grid-cols-[1fr_auto] gap-x-4"
|
||||
@click="setting.notImplemented ? $event.preventDefault() : undefined"
|
||||
v-if="setting.type === SettingType.Boolean" @update:checked="c => checked = c">
|
||||
<Switch.Label :data-disabled="setting.notImplemented ? '' : undefined"
|
||||
class="row-start-1 select-none text-base/6 data-[disabled]:opacity-50 sm:text-sm/6 text-white font-semibold">
|
||||
{{
|
||||
setting.title
|
||||
}}</Switch.Label>
|
||||
<p v-if="setting.notImplemented" class="text-xs mt-1 row-start-3 text-red-300 font-semibold">Not implemented
|
||||
</p>
|
||||
<p v-else :data-disabled="setting.notImplemented ? '' : undefined"
|
||||
class="text-base/6 row-start-2 data-[disabled]:opacity-50 sm:text-sm/6 text-gray-300">{{
|
||||
setting.description }}
|
||||
</p>
|
||||
<Switch.Control :data-disabled="setting.notImplemented ? '' : undefined"
|
||||
:data-checked="checked ? '' : undefined"
|
||||
class="group col-start-2 relative isolate inline-flex h-6 w-10 cursor-default rounded-full p-[3px] sm:h-5 sm:w-8 transition duration-0 ease-in-out data-[changing]:duration-200 forced-colors:outline forced-colors:[--switch-bg:Highlight] ring-1 ring-inset bg-white/5 ring-white/15 data-[checked]:bg-[--switch-bg] data-[checked]:ring-[--switch-bg-ring] focus:outline-none focus:outline focus:outline-2 focus:outline-offset-2 focus:outline-blue-500 hover:data-[checked]:ring-[--switch-bg-ring] hover:ring-white/25 data-[disabled]:bg-zinc-200 data-[disabled]:data-[checked]:bg-zinc-200 data-[disabled]:opacity-50 data-[disabled]:bg-white/15 data-[disabled]:data-[checked]:bg-white/15 data-[disabled]:data-[checked]:ring-white/15 [--switch-bg-ring:transparent] [--switch-bg:theme(colors.primary.600/25%)] [--switch-shadow:theme(colors.black/10%)] [--switch:white] [--switch-ring:theme(colors.white/10%)]">
|
||||
<Switch.Thumb
|
||||
class="pointer-events-none relative inline-block size-[1.125rem] rounded-full sm:size-3.5 translate-x-0 transition duration-200 ease-in-out border border-transparent bg-white shadow ring-1 ring-black/5 group-data-[checked]:bg-[--switch] group-data-[checked]:shadow-[--switch-shadow] group-data-[checked]:ring-[--switch-ring] group-data-[checked]:translate-x-4 sm:group-data-[checked]:translate-x-3 group-data-[disabled]:group-data-[checked]:bg-white group-data-[disabled]:group-data-[checked]:shadow group-data-[disabled]:group-data-[checked]:ring-black/5" />
|
||||
</Switch.Control>
|
||||
<Switch.HiddenInput />
|
||||
</Switch.Root>
|
||||
<div v-else class="grid grid-cols-[1fr_auto] gap-x-4">
|
||||
<h4 class="row-start-1 select-none text-base/6 sm:text-sm/6 text-white font-semibold">{{ setting.title }}
|
||||
</h4>
|
||||
<p class="text-xs mt-1 row-start-3 text-red-300 font-semibold">Not implemented</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { Switch } from "@ark-ui/vue";
|
||||
import { type Setting, type SettingIds, SettingType } from "~/settings";
|
||||
|
||||
const props = defineProps<{
|
||||
setting: Setting;
|
||||
}>();
|
||||
const checked = ref(!!props.setting.value);
|
||||
|
||||
const setting = useSetting(props.setting.id as SettingIds);
|
||||
|
||||
watch(checked, (c) => {
|
||||
setting.value.value = c;
|
||||
});
|
||||
</script>
|
||||
Loading…
Add table
Add a link
Reference in a new issue