feat: Add support for accounts on other instances

This commit is contained in:
Jesse Wierzbinski 2025-01-29 04:39:33 +01:00
parent 18eee4d481
commit 29b4cb43ca
No known key found for this signature in database
13 changed files with 179 additions and 40 deletions

17
app.vue
View file

@ -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) => {

View file

@ -3,7 +3,7 @@ export type ConfirmModalOptions = {
message?: string;
confirmText?: string;
cancelText?: string;
inputType?: "none" | "text" | "textarea";
inputType?: "none" | "text" | "textarea" | "url";
defaultValue?: string;
};

View file

@ -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,11 +97,11 @@ confirmModalService.register({
</Button>
</AlertDialogCancel>
<AlertDialogAction :as-child="true">
<Button @click="handleConfirm">
<Button @click="handleConfirm" :disabled="!isValid && modalOptions.inputType === 'url'">
{{ modalOptions.confirmText }}
</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</template>
</template>

View file

@ -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...");
@ -164,4 +164,4 @@ const switchAccount = async (userId: string) => {
window.location.href = "/";
};
</script>
</script>

View file

@ -1 +1,2 @@
export { default as Input } from "./Input.vue";
export { default as UrlInput } from "./url.vue";

View 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>

View file

@ -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) {

View file

@ -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();

View file

@ -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();

View file

@ -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."
}

View file

@ -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."
}

View file

@ -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>

View file

@ -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(),