mirror of
https://github.com/versia-pub/frontend.git
synced 2025-12-06 08:28:20 +01:00
feat: ✨ Add support for accounts on other instances
This commit is contained in:
parent
18eee4d481
commit
29b4cb43ca
17
app.vue
17
app.vue
|
|
@ -27,6 +27,7 @@ const lang = useLanguage();
|
||||||
setLanguageTag(lang.value);
|
setLanguageTag(lang.value);
|
||||||
|
|
||||||
const code = useRequestURL().searchParams.get("code");
|
const code = useRequestURL().searchParams.get("code");
|
||||||
|
const origin = useRequestURL().searchParams.get("origin");
|
||||||
const appData = useAppData();
|
const appData = useAppData();
|
||||||
const instance = useInstance();
|
const instance = useInstance();
|
||||||
const description = useExtendedDescription(client);
|
const description = useExtendedDescription(client);
|
||||||
|
|
@ -72,8 +73,20 @@ useHead({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (code && appData.value && route.path !== "/oauth/code") {
|
if (code && origin && appData.value && route.path !== "/oauth/code") {
|
||||||
signInWithCode(code, appData.value);
|
const newOrigin = new URL(
|
||||||
|
URL.canParse(origin) ? origin : `https://${origin}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
signInWithCode(code, appData.value, newOrigin);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (origin && !code) {
|
||||||
|
const newOrigin = new URL(
|
||||||
|
URL.canParse(origin) ? origin : `https://${origin}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
signIn(appData, newOrigin);
|
||||||
}
|
}
|
||||||
|
|
||||||
useListen("identity:change", (newIdentity) => {
|
useListen("identity:change", (newIdentity) => {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ export type ConfirmModalOptions = {
|
||||||
message?: string;
|
message?: string;
|
||||||
confirmText?: string;
|
confirmText?: string;
|
||||||
cancelText?: string;
|
cancelText?: string;
|
||||||
inputType?: "none" | "text" | "textarea";
|
inputType?: "none" | "text" | "textarea" | "url";
|
||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import {
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from "@/components/ui/alert-dialog";
|
} from "@/components/ui/alert-dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input, UrlInput } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import * as m from "~/paraglide/messages.js";
|
import * as m from "~/paraglide/messages.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -32,6 +32,8 @@ const resolvePromise = ref<((result: ConfirmModalResult) => void) | null>(null);
|
||||||
|
|
||||||
function open(options: ConfirmModalOptions): Promise<ConfirmModalResult> {
|
function open(options: ConfirmModalOptions): Promise<ConfirmModalResult> {
|
||||||
isOpen.value = true;
|
isOpen.value = true;
|
||||||
|
isValid.value = false;
|
||||||
|
|
||||||
modalOptions.value = {
|
modalOptions.value = {
|
||||||
title: options.title || m.antsy_whole_alligator_blink(),
|
title: options.title || m.antsy_whole_alligator_blink(),
|
||||||
message: options.message,
|
message: options.message,
|
||||||
|
|
@ -68,6 +70,8 @@ function handleCancel() {
|
||||||
confirmModalService.register({
|
confirmModalService.register({
|
||||||
open,
|
open,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isValid = ref(false);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -82,6 +86,8 @@ confirmModalService.register({
|
||||||
|
|
||||||
<Input v-if="modalOptions.inputType === 'text'" v-model="inputValue" />
|
<Input v-if="modalOptions.inputType === 'text'" v-model="inputValue" />
|
||||||
|
|
||||||
|
<UrlInput v-if="modalOptions.inputType === 'url'" v-model="inputValue" placeholder="google.com" v-model:is-valid="isValid" />
|
||||||
|
|
||||||
<Textarea v-else-if="modalOptions.inputType === 'textarea'" v-model="inputValue" rows="6" />
|
<Textarea v-else-if="modalOptions.inputType === 'textarea'" v-model="inputValue" rows="6" />
|
||||||
|
|
||||||
<AlertDialogFooter class="w-full">
|
<AlertDialogFooter class="w-full">
|
||||||
|
|
@ -91,7 +97,7 @@ confirmModalService.register({
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogCancel>
|
</AlertDialogCancel>
|
||||||
<AlertDialogAction :as-child="true">
|
<AlertDialogAction :as-child="true">
|
||||||
<Button @click="handleConfirm">
|
<Button @click="handleConfirm" :disabled="!isValid && modalOptions.inputType === 'url'">
|
||||||
{{ modalOptions.confirmText }}
|
{{ modalOptions.confirmText }}
|
||||||
</Button>
|
</Button>
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,7 @@ import {
|
||||||
const appData = useAppData();
|
const appData = useAppData();
|
||||||
const isMobile = useMediaQuery("(max-width: 768px)");
|
const isMobile = useMediaQuery("(max-width: 768px)");
|
||||||
|
|
||||||
const signInAction = () => signIn(appData);
|
const signInAction = () => signIn(appData, new URL(useBaseUrl().value));
|
||||||
|
|
||||||
const signOut = async (userId?: string) => {
|
const signOut = async (userId?: string) => {
|
||||||
const id = toast.loading("Signing out...");
|
const id = toast.loading("Signing out...");
|
||||||
|
|
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
export { default as Input } from "./Input.vue";
|
export { default as Input } from "./Input.vue";
|
||||||
|
export { default as UrlInput } from "./url.vue";
|
||||||
|
|
|
||||||
53
components/ui/input/url.vue
Normal file
53
components/ui/input/url.vue
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useVModel } from "@vueuse/core";
|
||||||
|
import { Check, X } from "lucide-vue-next";
|
||||||
|
import type { HTMLAttributes } from "vue";
|
||||||
|
import * as m from "~/paraglide/messages.js";
|
||||||
|
import Input from "./Input.vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
defaultValue?: string | number;
|
||||||
|
modelValue?: string | number;
|
||||||
|
class?: HTMLAttributes["class"];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emits =
|
||||||
|
defineEmits<(e: "update:modelValue", payload: string | number) => void>();
|
||||||
|
|
||||||
|
const modelValue = useVModel(props, "modelValue", emits, {
|
||||||
|
passive: true,
|
||||||
|
defaultValue: props.defaultValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isValid = defineModel<boolean>("isValid");
|
||||||
|
|
||||||
|
const tryGuessUrl = (string: string) =>
|
||||||
|
URL.canParse(`https://${string}`) &&
|
||||||
|
string.includes(".") &&
|
||||||
|
string.length > 3 &&
|
||||||
|
string.charAt(string.length - 1) !== ".";
|
||||||
|
|
||||||
|
const isValidUrl = computed(
|
||||||
|
() =>
|
||||||
|
URL.canParse(modelValue.value as string) ||
|
||||||
|
tryGuessUrl(modelValue.value as string),
|
||||||
|
);
|
||||||
|
|
||||||
|
watch(modelValue, (value) => {
|
||||||
|
if (!URL.canParse(value as string) && tryGuessUrl(value as string)) {
|
||||||
|
modelValue.value = `https://${value}`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(isValidUrl, (value) => {
|
||||||
|
isValid.value = value;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<Input v-model="modelValue" v-bind="$attrs" />
|
||||||
|
<p v-if="isValidUrl" class="text-green-600 text-sm"><Check class="inline size-4" /> {{ m.sunny_small_warbler_express() }}</p>
|
||||||
|
<p v-else-if="(modelValue?.toString().length ?? 0) > 0" class="text-destructive text-sm"><X class="inline size-4" /> {{ m.teal_late_grebe_blend() }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
@ -44,7 +44,7 @@ export const useCacheRefresh = (client: MaybeRef<Client | null>) => {
|
||||||
|
|
||||||
// Get all permissions and deduplicate
|
// Get all permissions and deduplicate
|
||||||
const permissions = roles
|
const permissions = roles
|
||||||
.flatMap((r) => r.permissions)
|
?.flatMap((r) => r.permissions)
|
||||||
.filter((p, i, arr) => arr.indexOf(p) === i);
|
.filter((p, i, arr) => arr.indexOf(p) === i);
|
||||||
|
|
||||||
if (identity.value) {
|
if (identity.value) {
|
||||||
|
|
|
||||||
|
|
@ -2,23 +2,26 @@ import { Client, type Token } from "@versia/client";
|
||||||
import { toast } from "vue-sonner";
|
import { toast } from "vue-sonner";
|
||||||
|
|
||||||
export const useClient = (
|
export const useClient = (
|
||||||
|
origin?: MaybeRef<URL>,
|
||||||
customToken: MaybeRef<Token | null> = null,
|
customToken: MaybeRef<Token | null> = null,
|
||||||
): Ref<Client> => {
|
): Ref<Client> => {
|
||||||
return computed(
|
const apiHost = window.location.origin;
|
||||||
() =>
|
const domain = identity.value?.instance.domain;
|
||||||
new Client(
|
|
||||||
new URL(useBaseUrl().value),
|
return ref(
|
||||||
toValue(customToken)?.access_token ??
|
new Client(
|
||||||
identity.value?.tokens.access_token ??
|
toValue(origin) ??
|
||||||
undefined,
|
(domain ? new URL(`https://${domain}`) : new URL(apiHost)),
|
||||||
(error) => {
|
toValue(customToken)?.access_token ??
|
||||||
toast.error(
|
identity.value?.tokens.access_token ??
|
||||||
error.response.data.error ??
|
undefined,
|
||||||
"No error message provided",
|
(error) => {
|
||||||
);
|
toast.error(
|
||||||
},
|
error.response.data.error ?? "No error message provided",
|
||||||
),
|
);
|
||||||
);
|
},
|
||||||
|
),
|
||||||
|
) as Ref<Client>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const client = useClient();
|
export const client = useClient();
|
||||||
|
|
|
||||||
|
|
@ -37,7 +37,7 @@ import * as m from "~/paraglide/messages.js";
|
||||||
import { SettingIds } from "~/settings";
|
import { SettingIds } from "~/settings";
|
||||||
|
|
||||||
const appData = useAppData();
|
const appData = useAppData();
|
||||||
const signInAction = () => signIn(appData);
|
const signInAction = () => signIn(appData, new URL(useBaseUrl().value));
|
||||||
const colorMode = useColorMode();
|
const colorMode = useColorMode();
|
||||||
const themeSetting = useSetting(SettingIds.Theme);
|
const themeSetting = useSetting(SettingIds.Theme);
|
||||||
const { n, d } = useMagicKeys();
|
const { n, d } = useMagicKeys();
|
||||||
|
|
|
||||||
|
|
@ -347,5 +347,10 @@
|
||||||
"lower_formal_kudu_lift": "Gravatar email",
|
"lower_formal_kudu_lift": "Gravatar email",
|
||||||
"witty_honest_wallaby_support": "Preview",
|
"witty_honest_wallaby_support": "Preview",
|
||||||
"loud_tense_kitten_exhale": "Default visibility",
|
"loud_tense_kitten_exhale": "Default visibility",
|
||||||
"vivid_last_crocodile_offer": "The default visibility for new notes."
|
"vivid_last_crocodile_offer": "The default visibility for new notes.",
|
||||||
|
"muddy_topical_pelican_gasp": "Use another instance",
|
||||||
|
"sunny_small_warbler_express": "URL is valid",
|
||||||
|
"teal_late_grebe_blend": "URL is invalid",
|
||||||
|
"sharp_alive_anteater_fade": "Which instance?",
|
||||||
|
"noble_misty_rook_slide": "Put your instance's domain name here."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -329,5 +329,10 @@
|
||||||
"witty_honest_wallaby_support": "Aperçu",
|
"witty_honest_wallaby_support": "Aperçu",
|
||||||
"loud_tense_kitten_exhale": "Visibilité par défaut",
|
"loud_tense_kitten_exhale": "Visibilité par défaut",
|
||||||
"vivid_last_crocodile_offer": "La visibilité par défaut pour les nouvelles notes.",
|
"vivid_last_crocodile_offer": "La visibilité par défaut pour les nouvelles notes.",
|
||||||
"dirty_inclusive_meerkat_nudge": "Annuler"
|
"dirty_inclusive_meerkat_nudge": "Annuler",
|
||||||
|
"muddy_topical_pelican_gasp": "Utiliser une autre instance",
|
||||||
|
"sunny_small_warbler_express": "L'URL est valide",
|
||||||
|
"teal_late_grebe_blend": "L'URL n'est pas valide",
|
||||||
|
"sharp_alive_anteater_fade": "Quelle instance ?",
|
||||||
|
"noble_misty_rook_slide": "Mettez le nom de domaine de votre instance ici."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Client } from "@versia/client";
|
import { Client } from "@versia/client";
|
||||||
import { AlertCircle, Loader } from "lucide-vue-next";
|
import { AlertCircle, AppWindow, Loader } from "lucide-vue-next";
|
||||||
|
import { confirmModalService } from "~/components/modals/composable";
|
||||||
import UserAuthForm from "~/components/oauth/login.vue";
|
import UserAuthForm from "~/components/oauth/login.vue";
|
||||||
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
|
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
|
|
@ -11,19 +12,41 @@ useHead({
|
||||||
title: m.fuzzy_sea_moth_absorb(),
|
title: m.fuzzy_sea_moth_absorb(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const host = new URL(useBaseUrl().value).host;
|
const baseUrl = useBaseUrl();
|
||||||
const instance = useInstanceFromClient(new Client(new URL(useBaseUrl().value)));
|
const client = computed(() => new Client(new URL(baseUrl.value)));
|
||||||
|
const instance = useInstanceFromClient(client);
|
||||||
const {
|
const {
|
||||||
error,
|
error,
|
||||||
error_description,
|
error_description,
|
||||||
redirect_uri,
|
redirect_uri,
|
||||||
|
instance_switch_uri,
|
||||||
response_type,
|
response_type,
|
||||||
client_id,
|
client_id,
|
||||||
scope,
|
scope,
|
||||||
state,
|
|
||||||
} = useUrlSearchParams();
|
} = useUrlSearchParams();
|
||||||
const hasValidUrlSearchParams =
|
const hasValidUrlSearchParams =
|
||||||
redirect_uri && response_type && client_id && scope;
|
redirect_uri && response_type && client_id && scope;
|
||||||
|
|
||||||
|
const getHost = (uri: string) => new URL(uri).host;
|
||||||
|
|
||||||
|
const changeInstance = async () => {
|
||||||
|
const { confirmed, value } = await confirmModalService.confirm({
|
||||||
|
title: m.sharp_alive_anteater_fade(),
|
||||||
|
inputType: "url",
|
||||||
|
message: m.noble_misty_rook_slide(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (confirmed && value) {
|
||||||
|
// Redirect to the client's instance switch URI
|
||||||
|
const url = new URL(instance_switch_uri as string);
|
||||||
|
|
||||||
|
url.searchParams.set("origin", value);
|
||||||
|
|
||||||
|
await navigateTo(url.toString(), {
|
||||||
|
external: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -69,11 +92,17 @@ const hasValidUrlSearchParams =
|
||||||
{{ m.novel_fine_stork_snap() }}
|
{{ m.novel_fine_stork_snap() }}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-sm text-muted-foreground" v-html="m.smug_main_whale_snip({
|
<p class="text-sm text-muted-foreground" v-html="m.smug_main_whale_snip({
|
||||||
host,
|
host: getHost(baseUrl),
|
||||||
})">
|
})">
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<UserAuthForm v-if="instance && hasValidUrlSearchParams" :instance="instance" />
|
<template v-if="instance && hasValidUrlSearchParams">
|
||||||
|
<UserAuthForm :instance="instance" />
|
||||||
|
<Button variant="ghost" @click="changeInstance" v-if="instance_switch_uri">
|
||||||
|
<AppWindow />
|
||||||
|
{{ m.muddy_topical_pelican_gasp() }}
|
||||||
|
</Button>
|
||||||
|
</template>
|
||||||
<div v-else-if="hasValidUrlSearchParams" class="p-4 flex items-center justify-center h-48">
|
<div v-else-if="hasValidUrlSearchParams" class="p-4 flex items-center justify-center h-48">
|
||||||
<Loader class="size-8 animate-spin" />
|
<Loader class="size-8 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,24 @@
|
||||||
|
import type { Client } from "@versia/client";
|
||||||
import type { ApplicationData } from "@versia/client/types";
|
import type { ApplicationData } from "@versia/client/types";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
import { toast } from "vue-sonner";
|
import { toast } from "vue-sonner";
|
||||||
import * as m from "~/paraglide/messages.js";
|
import * as m from "~/paraglide/messages.js";
|
||||||
|
|
||||||
export const signIn = async (appData: Ref<ApplicationData | null>) => {
|
export const signIn = async (
|
||||||
|
appData: Ref<ApplicationData | null>,
|
||||||
|
origin: URL,
|
||||||
|
) => {
|
||||||
const id = toast.loading(m.level_due_ox_greet());
|
const id = toast.loading(m.level_due_ox_greet());
|
||||||
|
|
||||||
|
const redirectUri = new URL("/", useRequestURL().origin);
|
||||||
|
|
||||||
|
const client = useClient(origin);
|
||||||
|
|
||||||
|
redirectUri.searchParams.append("origin", client.value.url.origin);
|
||||||
|
|
||||||
const output = await client.value.createApp("Versia", {
|
const output = await client.value.createApp("Versia", {
|
||||||
scopes: ["read", "write", "follow", "push"],
|
scopes: ["read", "write", "follow", "push"],
|
||||||
redirect_uris: new URL("/", useRequestURL().origin).toString(),
|
redirect_uris: redirectUri.toString(),
|
||||||
website: useBaseUrl().value,
|
website: useBaseUrl().value,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -25,7 +35,7 @@ export const signIn = async (appData: Ref<ApplicationData | null>) => {
|
||||||
output.data.client_secret,
|
output.data.client_secret,
|
||||||
{
|
{
|
||||||
scopes: ["read", "write", "follow", "push"],
|
scopes: ["read", "write", "follow", "push"],
|
||||||
redirect_uri: new URL("/", useRequestURL().origin).toString(),
|
redirect_uri: redirectUri.toString(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -35,19 +45,33 @@ export const signIn = async (appData: Ref<ApplicationData | null>) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
window.location.href = url;
|
// Add "instance_switch_uri" parameter to URL
|
||||||
|
const toRedirect = new URL(url);
|
||||||
|
|
||||||
|
toRedirect.searchParams.append("instance_switch_uri", useRequestURL().href);
|
||||||
|
|
||||||
|
window.location.href = toRedirect.toString();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const signInWithCode = (code: string, appData: ApplicationData) => {
|
export const signInWithCode = (
|
||||||
|
code: string,
|
||||||
|
appData: ApplicationData,
|
||||||
|
origin: URL,
|
||||||
|
) => {
|
||||||
|
const client = useClient(origin);
|
||||||
|
const redirectUri = new URL("/", useRequestURL().origin);
|
||||||
|
|
||||||
|
redirectUri.searchParams.append("origin", client.value.url.origin);
|
||||||
|
|
||||||
client.value
|
client.value
|
||||||
?.fetchAccessToken(
|
?.fetchAccessToken(
|
||||||
appData.client_id,
|
appData.client_id,
|
||||||
appData.client_secret,
|
appData.client_secret,
|
||||||
code,
|
code,
|
||||||
new URL("/", useRequestURL().origin).toString(),
|
redirectUri.toString(),
|
||||||
)
|
)
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
const tempClient = useClient(res.data).value;
|
const tempClient = useClient(origin, res.data).value;
|
||||||
|
|
||||||
const [accountOutput, instanceOutput] = await Promise.all([
|
const [accountOutput, instanceOutput] = await Promise.all([
|
||||||
tempClient.verifyAccountCredentials(),
|
tempClient.verifyAccountCredentials(),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue