mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
feat: Add user registration via Web UI
This commit is contained in:
parent
1c295b4d8d
commit
d79e718e15
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"typescript.tsdk": "node_modules/typescript/lib"
|
||||
}
|
||||
|
|
@ -24,6 +24,18 @@ port = 40007
|
|||
api_key = ""
|
||||
enabled = true
|
||||
|
||||
[signups]
|
||||
# URL of your Terms of Service
|
||||
tos_url = "https://example.com/tos"
|
||||
# Whether to enable registrations or not
|
||||
registration = true
|
||||
rules = [
|
||||
"Do not harass others",
|
||||
"Be nice to people",
|
||||
"Don't spam",
|
||||
"Don't post illegal content",
|
||||
]
|
||||
|
||||
# Delete this section if you don't want to use custom OAuth providers
|
||||
# This is an example configuration
|
||||
# The provider MUST support OpenID Connect with .well-known discovery
|
||||
|
|
|
|||
|
|
@ -4,5 +4,7 @@ import '@unocss/reset/tailwind-compat.css'
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<router-view></router-view>
|
||||
<suspense>
|
||||
<router-view></router-view>
|
||||
</suspense>
|
||||
</template>
|
||||
149
pages/Register.vue
Normal file
149
pages/Register.vue
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
<template>
|
||||
<div class="flex min-h-screen flex-col justify-center px-6 py-12 lg:px-8 relative">
|
||||
<div class="absolute inset-x-0 -top-40 -z-10 transform-gpu overflow-hidden blur-3xl sm:-top-80" aria-hidden="true">
|
||||
<div class="relative left-[calc(50%-11rem)] aspect-[1155/678] w-[36.125rem] -translate-x-1/2 rotate-[30deg] bg-gradient-to-tr from-[#ff80b5] to-[#9089fc] opacity-30 sm:left-[calc(50%-30rem)] sm:w-[72.1875rem]"
|
||||
style="clip-path: polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)" />
|
||||
</div>
|
||||
<div v-if="instanceInfo.registrations" class="mt-10 sm:mx-auto sm:w-full sm:max-w-sm">
|
||||
<form ref="form" class="space-y-6" method="POST" action="" @submit.prevent="registerUser">
|
||||
<div>
|
||||
<h1 class="font-bold text-2xl text-center tracking-tight">Register for an account</h1>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<LoginInput label="Email" id="email" name="email" type="email" autocomplete="email" required />
|
||||
</div>
|
||||
|
||||
<div v-if="errors['email']" v-for="error of errors['email']"
|
||||
class="rounded bg-purple-100 ring-1 ring-purple-800 py-2 px-4">
|
||||
<h3 class="font-bold">An error occured:</h3>
|
||||
<p>{{ error.description }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<LoginInput label="Username" id="username" name="username" type="text" autocomplete="username"
|
||||
required />
|
||||
</div>
|
||||
|
||||
<div v-if="errors['username']" v-for="error of errors['username']"
|
||||
class="rounded bg-purple-100 ring-1 ring-purple-800 py-2 px-4">
|
||||
<h3 class="font-bold">An error occured:</h3>
|
||||
<p>{{ error.description }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<LoginInput label="Password" id="password" name="password" type="password" autocomplete="" required
|
||||
:spellcheck="false" :error="!passwordsMatch ? `Passwords dont match` : ``" :value="password1"
|
||||
@input="password1 = $event.target.value" />
|
||||
</div>
|
||||
|
||||
<div v-if="errors['password']" v-for="error of errors['password']"
|
||||
class="rounded bg-purple-100 ring-1 ring-purple-800 py-2 px-4">
|
||||
<h3 class="font-bold">An error occured:</h3>
|
||||
<p>{{ error.description }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<LoginInput label="Confirm password" id="password2" name="password2" type="password" autocomplete=""
|
||||
required :spellcheck="false" :error="!passwordsMatch ? `Passwords dont match` : ``"
|
||||
:value="password2" @input="password2 = $event.target.value" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="comment" class="block text-sm font-medium leading-6 text-gray-900">Why do you want to
|
||||
join?</label>
|
||||
<div class="mt-2">
|
||||
<textarea rows="4" required :value="reason" @input="reason = ($event.target as any).value"
|
||||
name="comment" id="comment"
|
||||
class="block w-full rounded-md px-2 border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="errors['reason']" v-for="error of errors['reason']"
|
||||
class="rounded bg-purple-100 ring-1 ring-purple-800 py-2 px-4">
|
||||
<h3 class="font-bold">An error occured:</h3>
|
||||
<p>{{ error.description }}</p>
|
||||
</div>
|
||||
|
||||
<div class="">
|
||||
<input type="checkbox" :value="tosAccepted" @input="tosAccepted = Boolean(($event.target as any).value)"
|
||||
class="rounded mr-1 align-middle mb-0.5" /> <span class="text-sm">I agree to the
|
||||
terms and
|
||||
conditions
|
||||
of this
|
||||
server, available <a class="underline font-bold" target="_blank"
|
||||
:href="instanceInfo.tos_url">here</a></span>
|
||||
</div>
|
||||
|
||||
<div v-if="errors['agreement']" v-for="error of errors['agreement']"
|
||||
class="rounded bg-purple-100 ring-1 ring-purple-800 py-2 px-4">
|
||||
<h3 class="font-bold">An error occured:</h3>
|
||||
<p>{{ error.description }}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button type="submit" :disabled="!passwordsMatch || !tosAccepted"
|
||||
class="flex w-full justify-center disabled:opacity-50 disabled:hover:shadow-none rounded-md bg-purple-600 px-3 py-1.5 text-sm font-semibold leading-6 text-white shadow-sm hover:shadow-lg duration-200 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600">Sign
|
||||
in</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div v-else>
|
||||
<h1 class="text-2xl font-bold tracking-tight text-gray-900 sm:text-4xl text-center">Registrations are disabled
|
||||
</h1>
|
||||
<p class="mt-6 text-lg leading-8 text-gray-600 text-center">Ask this instance's admin to enable them in config!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { APIInstance } from "~types/entities/instance";
|
||||
import LoginInput from "./components/LoginInput.vue"
|
||||
import { computed, ref, watch } from 'vue';
|
||||
|
||||
const instanceInfo = await fetch("/api/v1/instance").then(res => res.json()) as APIInstance & {
|
||||
tos_url: string
|
||||
};
|
||||
|
||||
const errors = ref<{
|
||||
[key: string]: {
|
||||
error: string;
|
||||
description: string;
|
||||
}[];
|
||||
}>({});
|
||||
|
||||
const password1 = ref<string>("");
|
||||
const password2 = ref<string>("");
|
||||
const tosAccepted = ref<boolean>(false);
|
||||
const reason = ref<string>("");
|
||||
|
||||
const passwordsMatch = computed(() => password1.value === password2.value);
|
||||
|
||||
const registerUser = (e: Event) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData();
|
||||
|
||||
formData.append("email", (e.target as any).email.value);
|
||||
formData.append("password", (e.target as any).password.value);
|
||||
formData.append("username", (e.target as any).username.value);
|
||||
formData.append("reason", reason.value);
|
||||
formData.append("locale", "en")
|
||||
formData.append("agreement", "true");
|
||||
// @ts-ignore
|
||||
fetch("/api/v1/accounts", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
}).then(async res => {
|
||||
if (res.status === 422) {
|
||||
errors.value = (await res.json() as any).details;
|
||||
console.log(errors.value)
|
||||
} else {
|
||||
// @ts-ignore
|
||||
window.location.href = "/register/success";
|
||||
}
|
||||
}).catch(async err => {
|
||||
console.error(err);
|
||||
})
|
||||
}
|
||||
</script>
|
||||
14
pages/RegistrationSuccess.vue
Normal file
14
pages/RegistrationSuccess.vue
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<template>
|
||||
<div class="flex min-h-screen flex-col justify-center px-6 py-12 lg:px-8 relative">
|
||||
<div class="absolute inset-x-0 -top-40 -z-10 transform-gpu overflow-hidden blur-3xl sm:-top-80" aria-hidden="true">
|
||||
<div class="relative left-[calc(50%-11rem)] aspect-[1155/678] w-[36.125rem] -translate-x-1/2 rotate-[30deg] bg-gradient-to-tr from-[#ff80b5] to-[#9089fc] opacity-30 sm:left-[calc(50%-30rem)] sm:w-[72.1875rem]"
|
||||
style="clip-path: polygon(74.1% 44.1%, 100% 61.6%, 97.5% 26.9%, 85.5% 0.1%, 80.7% 2%, 72.5% 32.5%, 60.2% 62.4%, 52.4% 68.1%, 47.5% 58.3%, 45.2% 34.5%, 27.5% 76.7%, 0.1% 64.9%, 17.9% 100%, 27.6% 76.8%, 76.1% 97.7%, 74.1% 44.1%)" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold tracking-tight text-gray-900 sm:text-4xl text-center">Registration was a success!
|
||||
</h1>
|
||||
<p class="mt-6 text-lg leading-8 text-gray-600 text-center"> You can now login to your account in any Mastodon
|
||||
client </p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -4,8 +4,8 @@
|
|||
</div>
|
||||
<div class="mt-2">
|
||||
<input v-bind="$attrs" @input="checkValid" :class="['block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6',
|
||||
isInvalid && 'invalid:!ring-red-600 invalid:ring-2']">
|
||||
<span v-if="isInvalid" class="mt-1 text-xs text-red-600">{{ label }} is invalid</span>
|
||||
(isInvalid || error) && 'invalid:!ring-red-600 invalid:ring-2']">
|
||||
<span v-if="isInvalid || error" class="mt-1 text-xs text-red-600">{{ error ? error : `${label} is invalid` }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -14,6 +14,7 @@ import { ref } from 'vue';
|
|||
|
||||
const props = defineProps<{
|
||||
label: string;
|
||||
error?: string;
|
||||
}>();
|
||||
|
||||
const isInvalid = ref(false);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import Login from "./login.vue";
|
||||
import Home from "./Home.vue";
|
||||
import Register from "./Register.vue";
|
||||
import RegistrationSuccess from "./RegistrationSuccess.vue";
|
||||
|
||||
export default [
|
||||
{ path: "/", component: Home },
|
||||
{ path: "/oauth/authorize", component: Login },
|
||||
{ path: "/register", component: Register },
|
||||
{ path: "/register/success", component: RegistrationSuccess },
|
||||
];
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ export const meta = applyConfig({
|
|||
duration: 60,
|
||||
},
|
||||
auth: {
|
||||
required: true,
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -176,10 +176,13 @@ export default async (req: Request): Promise<Response> => {
|
|||
.join(", ")}`
|
||||
)
|
||||
.join(", ");
|
||||
return jsonResponse({
|
||||
error: `Validation failed: ${errorsText}`,
|
||||
details: errors.details,
|
||||
});
|
||||
return jsonResponse(
|
||||
{
|
||||
error: `Validation failed: ${errorsText}`,
|
||||
details: errors.details,
|
||||
},
|
||||
422
|
||||
);
|
||||
}
|
||||
|
||||
await createNewLocalUser({
|
||||
|
|
|
|||
|
|
@ -101,15 +101,19 @@ export default async (): Promise<Response> => {
|
|||
description: "A test instance",
|
||||
email: "",
|
||||
invites_enabled: false,
|
||||
registrations: true,
|
||||
registrations: config.signups.registration,
|
||||
languages: ["en"],
|
||||
rules: [],
|
||||
rules: config.signups.rules.map((r, index) => ({
|
||||
id: String(index),
|
||||
text: r,
|
||||
})),
|
||||
stats: {
|
||||
domain_count: knownDomainsCount,
|
||||
status_count: statusCount,
|
||||
user_count: userCount,
|
||||
},
|
||||
thumbnail: "",
|
||||
tos_url: config.signups.tos_url,
|
||||
title: "Test Instance",
|
||||
uri: new URL(config.http.base_url).hostname,
|
||||
urls: {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext"],
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"module": "esnext",
|
||||
"target": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { APIStats } from "./stats";
|
|||
import type { APIURLs } from "./urls";
|
||||
|
||||
export interface APIInstance {
|
||||
tos_url: string | undefined;
|
||||
uri: string;
|
||||
title: string;
|
||||
description: string;
|
||||
|
|
|
|||
|
|
@ -50,6 +50,12 @@ export interface ConfigType {
|
|||
enabled: boolean;
|
||||
};
|
||||
|
||||
signups: {
|
||||
tos_url: string;
|
||||
rules: string[];
|
||||
registration: boolean;
|
||||
};
|
||||
|
||||
oidc: {
|
||||
providers: {
|
||||
name: string;
|
||||
|
|
@ -218,6 +224,11 @@ export const configDefaults: ConfigType = {
|
|||
api_key: "",
|
||||
enabled: false,
|
||||
},
|
||||
signups: {
|
||||
tos_url: "",
|
||||
rules: [],
|
||||
registration: false,
|
||||
},
|
||||
oidc: {
|
||||
providers: [],
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue