Skip to content

Commit 29b4cb4

Browse files
committed
feat: ✨ Add support for accounts on other instances
1 parent 18eee4d commit 29b4cb4

File tree

13 files changed

+179
-40
lines changed

13 files changed

+179
-40
lines changed

app.vue

+15-2
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const lang = useLanguage();
2727
setLanguageTag(lang.value);
2828
2929
const code = useRequestURL().searchParams.get("code");
30+
const origin = useRequestURL().searchParams.get("origin");
3031
const appData = useAppData();
3132
const instance = useInstance();
3233
const description = useExtendedDescription(client);
@@ -72,8 +73,20 @@ useHead({
7273
},
7374
});
7475
75-
if (code && appData.value && route.path !== "/oauth/code") {
76-
signInWithCode(code, appData.value);
76+
if (code && origin && appData.value && route.path !== "/oauth/code") {
77+
const newOrigin = new URL(
78+
URL.canParse(origin) ? origin : `https://${origin}`,
79+
);
80+
81+
signInWithCode(code, appData.value, newOrigin);
82+
}
83+
84+
if (origin && !code) {
85+
const newOrigin = new URL(
86+
URL.canParse(origin) ? origin : `https://${origin}`,
87+
);
88+
89+
signIn(appData, newOrigin);
7790
}
7891
7992
useListen("identity:change", (newIdentity) => {

components/modals/composable.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ export type ConfirmModalOptions = {
33
message?: string;
44
confirmText?: string;
55
cancelText?: string;
6-
inputType?: "none" | "text" | "textarea";
6+
inputType?: "none" | "text" | "textarea" | "url";
77
defaultValue?: string;
88
};
99

components/modals/confirm.vue

+9-3
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
AlertDialogTitle,
1111
} from "@/components/ui/alert-dialog";
1212
import { Button } from "@/components/ui/button";
13-
import { Input } from "@/components/ui/input";
13+
import { Input, UrlInput } from "@/components/ui/input";
1414
import { Textarea } from "@/components/ui/textarea";
1515
import * as m from "~/paraglide/messages.js";
1616
import {
@@ -32,6 +32,8 @@ const resolvePromise = ref<((result: ConfirmModalResult) => void) | null>(null);
3232
3333
function open(options: ConfirmModalOptions): Promise<ConfirmModalResult> {
3434
isOpen.value = true;
35+
isValid.value = false;
36+
3537
modalOptions.value = {
3638
title: options.title || m.antsy_whole_alligator_blink(),
3739
message: options.message,
@@ -68,6 +70,8 @@ function handleCancel() {
6870
confirmModalService.register({
6971
open,
7072
});
73+
74+
const isValid = ref(false);
7175
</script>
7276

7377
<template>
@@ -82,6 +86,8 @@ confirmModalService.register({
8286

8387
<Input v-if="modalOptions.inputType === 'text'" v-model="inputValue" />
8488

89+
<UrlInput v-if="modalOptions.inputType === 'url'" v-model="inputValue" placeholder="google.com" v-model:is-valid="isValid" />
90+
8591
<Textarea v-else-if="modalOptions.inputType === 'textarea'" v-model="inputValue" rows="6" />
8692

8793
<AlertDialogFooter class="w-full">
@@ -91,11 +97,11 @@ confirmModalService.register({
9197
</Button>
9298
</AlertDialogCancel>
9399
<AlertDialogAction :as-child="true">
94-
<Button @click="handleConfirm">
100+
<Button @click="handleConfirm" :disabled="!isValid && modalOptions.inputType === 'url'">
95101
{{ modalOptions.confirmText }}
96102
</Button>
97103
</AlertDialogAction>
98104
</AlertDialogFooter>
99105
</AlertDialogContent>
100106
</AlertDialog>
101-
</template>
107+
</template>

components/sidebars/account-switcher.vue

+2-2
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ import {
9898
const appData = useAppData();
9999
const isMobile = useMediaQuery("(max-width: 768px)");
100100
101-
const signInAction = () => signIn(appData);
101+
const signInAction = () => signIn(appData, new URL(useBaseUrl().value));
102102
103103
const signOut = async (userId?: string) => {
104104
const id = toast.loading("Signing out...");
@@ -164,4 +164,4 @@ const switchAccount = async (userId: string) => {
164164
165165
window.location.href = "/";
166166
};
167-
</script>
167+
</script>

components/ui/input/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export { default as Input } from "./Input.vue";
2+
export { default as UrlInput } from "./url.vue";

components/ui/input/url.vue

+53
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<script setup lang="ts">
2+
import { useVModel } from "@vueuse/core";
3+
import { Check, X } from "lucide-vue-next";
4+
import type { HTMLAttributes } from "vue";
5+
import * as m from "~/paraglide/messages.js";
6+
import Input from "./Input.vue";
7+
8+
const props = defineProps<{
9+
defaultValue?: string | number;
10+
modelValue?: string | number;
11+
class?: HTMLAttributes["class"];
12+
}>();
13+
14+
const emits =
15+
defineEmits<(e: "update:modelValue", payload: string | number) => void>();
16+
17+
const modelValue = useVModel(props, "modelValue", emits, {
18+
passive: true,
19+
defaultValue: props.defaultValue,
20+
});
21+
22+
const isValid = defineModel<boolean>("isValid");
23+
24+
const tryGuessUrl = (string: string) =>
25+
URL.canParse(`https://${string}`) &&
26+
string.includes(".") &&
27+
string.length > 3 &&
28+
string.charAt(string.length - 1) !== ".";
29+
30+
const isValidUrl = computed(
31+
() =>
32+
URL.canParse(modelValue.value as string) ||
33+
tryGuessUrl(modelValue.value as string),
34+
);
35+
36+
watch(modelValue, (value) => {
37+
if (!URL.canParse(value as string) && tryGuessUrl(value as string)) {
38+
modelValue.value = `https://${value}`;
39+
}
40+
});
41+
42+
watch(isValidUrl, (value) => {
43+
isValid.value = value;
44+
});
45+
</script>
46+
47+
<template>
48+
<div class="space-y-3">
49+
<Input v-model="modelValue" v-bind="$attrs" />
50+
<p v-if="isValidUrl" class="text-green-600 text-sm"><Check class="inline size-4" /> {{ m.sunny_small_warbler_express() }}</p>
51+
<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>
52+
</div>
53+
</template>

composables/CacheRefresh.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export const useCacheRefresh = (client: MaybeRef<Client | null>) => {
4444

4545
// Get all permissions and deduplicate
4646
const permissions = roles
47-
.flatMap((r) => r.permissions)
47+
?.flatMap((r) => r.permissions)
4848
.filter((p, i, arr) => arr.indexOf(p) === i);
4949

5050
if (identity.value) {

composables/Client.ts

+18-15
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,26 @@ import { Client, type Token } from "@versia/client";
22
import { toast } from "vue-sonner";
33

44
export const useClient = (
5+
origin?: MaybeRef<URL>,
56
customToken: MaybeRef<Token | null> = null,
67
): Ref<Client> => {
7-
return computed(
8-
() =>
9-
new Client(
10-
new URL(useBaseUrl().value),
11-
toValue(customToken)?.access_token ??
12-
identity.value?.tokens.access_token ??
13-
undefined,
14-
(error) => {
15-
toast.error(
16-
error.response.data.error ??
17-
"No error message provided",
18-
);
19-
},
20-
),
21-
);
8+
const apiHost = window.location.origin;
9+
const domain = identity.value?.instance.domain;
10+
11+
return ref(
12+
new Client(
13+
toValue(origin) ??
14+
(domain ? new URL(`https://${domain}`) : new URL(apiHost)),
15+
toValue(customToken)?.access_token ??
16+
identity.value?.tokens.access_token ??
17+
undefined,
18+
(error) => {
19+
toast.error(
20+
error.response.data.error ?? "No error message provided",
21+
);
22+
},
23+
),
24+
) as Ref<Client>;
2225
};
2326

2427
export const client = useClient();

layouts/app.vue

+1-1
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ import * as m from "~/paraglide/messages.js";
3737
import { SettingIds } from "~/settings";
3838
3939
const appData = useAppData();
40-
const signInAction = () => signIn(appData);
40+
const signInAction = () => signIn(appData, new URL(useBaseUrl().value));
4141
const colorMode = useColorMode();
4242
const themeSetting = useSetting(SettingIds.Theme);
4343
const { n, d } = useMagicKeys();

messages/en.json

+6-1
Original file line numberDiff line numberDiff line change
@@ -347,5 +347,10 @@
347347
"lower_formal_kudu_lift": "Gravatar email",
348348
"witty_honest_wallaby_support": "Preview",
349349
"loud_tense_kitten_exhale": "Default visibility",
350-
"vivid_last_crocodile_offer": "The default visibility for new notes."
350+
"vivid_last_crocodile_offer": "The default visibility for new notes.",
351+
"muddy_topical_pelican_gasp": "Use another instance",
352+
"sunny_small_warbler_express": "URL is valid",
353+
"teal_late_grebe_blend": "URL is invalid",
354+
"sharp_alive_anteater_fade": "Which instance?",
355+
"noble_misty_rook_slide": "Put your instance's domain name here."
351356
}

messages/fr.json

+6-1
Original file line numberDiff line numberDiff line change
@@ -329,5 +329,10 @@
329329
"witty_honest_wallaby_support": "Aperçu",
330330
"loud_tense_kitten_exhale": "Visibilité par défaut",
331331
"vivid_last_crocodile_offer": "La visibilité par défaut pour les nouvelles notes.",
332-
"dirty_inclusive_meerkat_nudge": "Annuler"
332+
"dirty_inclusive_meerkat_nudge": "Annuler",
333+
"muddy_topical_pelican_gasp": "Utiliser une autre instance",
334+
"sunny_small_warbler_express": "L'URL est valide",
335+
"teal_late_grebe_blend": "L'URL n'est pas valide",
336+
"sharp_alive_anteater_fade": "Quelle instance ?",
337+
"noble_misty_rook_slide": "Mettez le nom de domaine de votre instance ici."
333338
}

pages/oauth/authorize.vue

+35-6
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
<script setup lang="ts">
22
import { Client } from "@versia/client";
3-
import { AlertCircle, Loader } from "lucide-vue-next";
3+
import { AlertCircle, AppWindow, Loader } from "lucide-vue-next";
4+
import { confirmModalService } from "~/components/modals/composable";
45
import UserAuthForm from "~/components/oauth/login.vue";
56
import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert";
67
import { Button } from "~/components/ui/button";
@@ -11,19 +12,41 @@ useHead({
1112
title: m.fuzzy_sea_moth_absorb(),
1213
});
1314
14-
const host = new URL(useBaseUrl().value).host;
15-
const instance = useInstanceFromClient(new Client(new URL(useBaseUrl().value)));
15+
const baseUrl = useBaseUrl();
16+
const client = computed(() => new Client(new URL(baseUrl.value)));
17+
const instance = useInstanceFromClient(client);
1618
const {
1719
error,
1820
error_description,
1921
redirect_uri,
22+
instance_switch_uri,
2023
response_type,
2124
client_id,
2225
scope,
23-
state,
2426
} = useUrlSearchParams();
2527
const hasValidUrlSearchParams =
2628
redirect_uri && response_type && client_id && scope;
29+
30+
const getHost = (uri: string) => new URL(uri).host;
31+
32+
const changeInstance = async () => {
33+
const { confirmed, value } = await confirmModalService.confirm({
34+
title: m.sharp_alive_anteater_fade(),
35+
inputType: "url",
36+
message: m.noble_misty_rook_slide(),
37+
});
38+
39+
if (confirmed && value) {
40+
// Redirect to the client's instance switch URI
41+
const url = new URL(instance_switch_uri as string);
42+
43+
url.searchParams.set("origin", value);
44+
45+
await navigateTo(url.toString(), {
46+
external: true,
47+
});
48+
}
49+
};
2750
</script>
2851

2952
<template>
@@ -69,11 +92,17 @@ const hasValidUrlSearchParams =
6992
{{ m.novel_fine_stork_snap() }}
7093
</h1>
7194
<p class="text-sm text-muted-foreground" v-html="m.smug_main_whale_snip({
72-
host,
95+
host: getHost(baseUrl),
7396
})">
7497
</p>
7598
</div>
76-
<UserAuthForm v-if="instance && hasValidUrlSearchParams" :instance="instance" />
99+
<template v-if="instance && hasValidUrlSearchParams">
100+
<UserAuthForm :instance="instance" />
101+
<Button variant="ghost" @click="changeInstance" v-if="instance_switch_uri">
102+
<AppWindow />
103+
{{ m.muddy_topical_pelican_gasp() }}
104+
</Button>
105+
</template>
77106
<div v-else-if="hasValidUrlSearchParams" class="p-4 flex items-center justify-center h-48">
78107
<Loader class="size-8 animate-spin" />
79108
</div>

utils/auth.ts

+31-7
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,24 @@
1+
import type { Client } from "@versia/client";
12
import type { ApplicationData } from "@versia/client/types";
23
import { nanoid } from "nanoid";
34
import { toast } from "vue-sonner";
45
import * as m from "~/paraglide/messages.js";
56

6-
export const signIn = async (appData: Ref<ApplicationData | null>) => {
7+
export const signIn = async (
8+
appData: Ref<ApplicationData | null>,
9+
origin: URL,
10+
) => {
711
const id = toast.loading(m.level_due_ox_greet());
812

13+
const redirectUri = new URL("/", useRequestURL().origin);
14+
15+
const client = useClient(origin);
16+
17+
redirectUri.searchParams.append("origin", client.value.url.origin);
18+
919
const output = await client.value.createApp("Versia", {
1020
scopes: ["read", "write", "follow", "push"],
11-
redirect_uris: new URL("/", useRequestURL().origin).toString(),
21+
redirect_uris: redirectUri.toString(),
1222
website: useBaseUrl().value,
1323
});
1424

@@ -25,7 +35,7 @@ export const signIn = async (appData: Ref<ApplicationData | null>) => {
2535
output.data.client_secret,
2636
{
2737
scopes: ["read", "write", "follow", "push"],
28-
redirect_uri: new URL("/", useRequestURL().origin).toString(),
38+
redirect_uri: redirectUri.toString(),
2939
},
3040
);
3141

@@ -35,19 +45,33 @@ export const signIn = async (appData: Ref<ApplicationData | null>) => {
3545
return;
3646
}
3747

38-
window.location.href = url;
48+
// Add "instance_switch_uri" parameter to URL
49+
const toRedirect = new URL(url);
50+
51+
toRedirect.searchParams.append("instance_switch_uri", useRequestURL().href);
52+
53+
window.location.href = toRedirect.toString();
3954
};
4055

41-
export const signInWithCode = (code: string, appData: ApplicationData) => {
56+
export const signInWithCode = (
57+
code: string,
58+
appData: ApplicationData,
59+
origin: URL,
60+
) => {
61+
const client = useClient(origin);
62+
const redirectUri = new URL("/", useRequestURL().origin);
63+
64+
redirectUri.searchParams.append("origin", client.value.url.origin);
65+
4266
client.value
4367
?.fetchAccessToken(
4468
appData.client_id,
4569
appData.client_secret,
4670
code,
47-
new URL("/", useRequestURL().origin).toString(),
71+
redirectUri.toString(),
4872
)
4973
.then(async (res) => {
50-
const tempClient = useClient(res.data).value;
74+
const tempClient = useClient(origin, res.data).value;
5175

5276
const [accountOutput, instanceOutput] = await Promise.all([
5377
tempClient.verifyAccountCredentials(),

0 commit comments

Comments
 (0)