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);
|
||||
|
||||
const code = useRequestURL().searchParams.get("code");
|
||||
const origin = useRequestURL().searchParams.get("origin");
|
||||
const appData = useAppData();
|
||||
const instance = useInstance();
|
||||
const description = useExtendedDescription(client);
|
||||
|
|
@ -72,8 +73,20 @@ useHead({
|
|||
},
|
||||
});
|
||||
|
||||
if (code && appData.value && route.path !== "/oauth/code") {
|
||||
signInWithCode(code, appData.value);
|
||||
if (code && origin && appData.value && route.path !== "/oauth/code") {
|
||||
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) => {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ export type ConfirmModalOptions = {
|
|||
message?: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
inputType?: "none" | "text" | "textarea";
|
||||
inputType?: "none" | "text" | "textarea" | "url";
|
||||
defaultValue?: string;
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
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 * as m from "~/paraglide/messages.js";
|
||||
import {
|
||||
|
|
@ -32,6 +32,8 @@ const resolvePromise = ref<((result: ConfirmModalResult) => void) | null>(null);
|
|||
|
||||
function open(options: ConfirmModalOptions): Promise<ConfirmModalResult> {
|
||||
isOpen.value = true;
|
||||
isValid.value = false;
|
||||
|
||||
modalOptions.value = {
|
||||
title: options.title || m.antsy_whole_alligator_blink(),
|
||||
message: options.message,
|
||||
|
|
@ -68,6 +70,8 @@ function handleCancel() {
|
|||
confirmModalService.register({
|
||||
open,
|
||||
});
|
||||
|
||||
const isValid = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -82,6 +86,8 @@ confirmModalService.register({
|
|||
|
||||
<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" />
|
||||
|
||||
<AlertDialogFooter class="w-full">
|
||||
|
|
@ -91,7 +97,7 @@ confirmModalService.register({
|
|||
</Button>
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction :as-child="true">
|
||||
<Button @click="handleConfirm">
|
||||
<Button @click="handleConfirm" :disabled="!isValid && modalOptions.inputType === 'url'">
|
||||
{{ modalOptions.confirmText }}
|
||||
</Button>
|
||||
</AlertDialogAction>
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ import {
|
|||
const appData = useAppData();
|
||||
const isMobile = useMediaQuery("(max-width: 768px)");
|
||||
|
||||
const signInAction = () => signIn(appData);
|
||||
const signInAction = () => signIn(appData, new URL(useBaseUrl().value));
|
||||
|
||||
const signOut = async (userId?: string) => {
|
||||
const id = toast.loading("Signing out...");
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
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
|
||||
const permissions = roles
|
||||
.flatMap((r) => r.permissions)
|
||||
?.flatMap((r) => r.permissions)
|
||||
.filter((p, i, arr) => arr.indexOf(p) === i);
|
||||
|
||||
if (identity.value) {
|
||||
|
|
|
|||
|
|
@ -2,23 +2,26 @@ import { Client, type Token } from "@versia/client";
|
|||
import { toast } from "vue-sonner";
|
||||
|
||||
export const useClient = (
|
||||
origin?: MaybeRef<URL>,
|
||||
customToken: MaybeRef<Token | null> = null,
|
||||
): Ref<Client> => {
|
||||
return computed(
|
||||
() =>
|
||||
new Client(
|
||||
new URL(useBaseUrl().value),
|
||||
toValue(customToken)?.access_token ??
|
||||
identity.value?.tokens.access_token ??
|
||||
undefined,
|
||||
(error) => {
|
||||
toast.error(
|
||||
error.response.data.error ??
|
||||
"No error message provided",
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
const apiHost = window.location.origin;
|
||||
const domain = identity.value?.instance.domain;
|
||||
|
||||
return ref(
|
||||
new Client(
|
||||
toValue(origin) ??
|
||||
(domain ? new URL(`https://${domain}`) : new URL(apiHost)),
|
||||
toValue(customToken)?.access_token ??
|
||||
identity.value?.tokens.access_token ??
|
||||
undefined,
|
||||
(error) => {
|
||||
toast.error(
|
||||
error.response.data.error ?? "No error message provided",
|
||||
);
|
||||
},
|
||||
),
|
||||
) as Ref<Client>;
|
||||
};
|
||||
|
||||
export const client = useClient();
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ import * as m from "~/paraglide/messages.js";
|
|||
import { SettingIds } from "~/settings";
|
||||
|
||||
const appData = useAppData();
|
||||
const signInAction = () => signIn(appData);
|
||||
const signInAction = () => signIn(appData, new URL(useBaseUrl().value));
|
||||
const colorMode = useColorMode();
|
||||
const themeSetting = useSetting(SettingIds.Theme);
|
||||
const { n, d } = useMagicKeys();
|
||||
|
|
|
|||
|
|
@ -347,5 +347,10 @@
|
|||
"lower_formal_kudu_lift": "Gravatar email",
|
||||
"witty_honest_wallaby_support": "Preview",
|
||||
"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",
|
||||
"loud_tense_kitten_exhale": "Visibilité par défaut",
|
||||
"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">
|
||||
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 { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
|
||||
import { Button } from "~/components/ui/button";
|
||||
|
|
@ -11,19 +12,41 @@ useHead({
|
|||
title: m.fuzzy_sea_moth_absorb(),
|
||||
});
|
||||
|
||||
const host = new URL(useBaseUrl().value).host;
|
||||
const instance = useInstanceFromClient(new Client(new URL(useBaseUrl().value)));
|
||||
const baseUrl = useBaseUrl();
|
||||
const client = computed(() => new Client(new URL(baseUrl.value)));
|
||||
const instance = useInstanceFromClient(client);
|
||||
const {
|
||||
error,
|
||||
error_description,
|
||||
redirect_uri,
|
||||
instance_switch_uri,
|
||||
response_type,
|
||||
client_id,
|
||||
scope,
|
||||
state,
|
||||
} = useUrlSearchParams();
|
||||
const hasValidUrlSearchParams =
|
||||
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>
|
||||
|
||||
<template>
|
||||
|
|
@ -69,11 +92,17 @@ const hasValidUrlSearchParams =
|
|||
{{ m.novel_fine_stork_snap() }}
|
||||
</h1>
|
||||
<p class="text-sm text-muted-foreground" v-html="m.smug_main_whale_snip({
|
||||
host,
|
||||
host: getHost(baseUrl),
|
||||
})">
|
||||
</p>
|
||||
</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">
|
||||
<Loader class="size-8 animate-spin" />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,14 +1,24 @@
|
|||
import type { Client } from "@versia/client";
|
||||
import type { ApplicationData } from "@versia/client/types";
|
||||
import { nanoid } from "nanoid";
|
||||
import { toast } from "vue-sonner";
|
||||
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 redirectUri = new URL("/", useRequestURL().origin);
|
||||
|
||||
const client = useClient(origin);
|
||||
|
||||
redirectUri.searchParams.append("origin", client.value.url.origin);
|
||||
|
||||
const output = await client.value.createApp("Versia", {
|
||||
scopes: ["read", "write", "follow", "push"],
|
||||
redirect_uris: new URL("/", useRequestURL().origin).toString(),
|
||||
redirect_uris: redirectUri.toString(),
|
||||
website: useBaseUrl().value,
|
||||
});
|
||||
|
||||
|
|
@ -25,7 +35,7 @@ export const signIn = async (appData: Ref<ApplicationData | null>) => {
|
|||
output.data.client_secret,
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
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
|
||||
?.fetchAccessToken(
|
||||
appData.client_id,
|
||||
appData.client_secret,
|
||||
code,
|
||||
new URL("/", useRequestURL().origin).toString(),
|
||||
redirectUri.toString(),
|
||||
)
|
||||
.then(async (res) => {
|
||||
const tempClient = useClient(res.data).value;
|
||||
const tempClient = useClient(origin, res.data).value;
|
||||
|
||||
const [accountOutput, instanceOutput] = await Promise.all([
|
||||
tempClient.verifyAccountCredentials(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue