Add e-mail based login

This commit is contained in:
2025-03-25 17:58:26 +01:00
parent 7aff2a2ead
commit 3fe0b43372
17 changed files with 499 additions and 305 deletions

View File

@@ -20,6 +20,7 @@ export default [
"text_upload",
"speedtest",
"upload_history",
"login",
].map((name, index) => ({
input: `src/${name}.js`,
output: {

View File

@@ -1,4 +1,4 @@
<i class="icon star" title="These options require a Persistence (€8) subscription to use">star</i>
<i class="icon star" title="These options require a Persistence (€8) subscription or Prepaid plan to use">star</i>
<style>
.star {

View File

@@ -1,4 +1,4 @@
<i class="icon star" title="These options require a Pro (€4) subscription to use">star</i>
<i class="icon star" title="These options require a Pro (€4) subscription or Prepaid plan to use">star</i>
<style>
.star {

View File

@@ -1,5 +1,7 @@
<script lang="ts">
export let on = false
export let icon_on = "check"
export let icon_off = "close"
export let group_first = false
export let group_middle = false
export let group_last = false
@@ -23,11 +25,10 @@ const click = (e: MouseEvent) => {
class:group_last
>
{#if on}
<i class="icon">check</i>
<i class="icon">{icon_on}</i>
{:else}
<i class="icon">close</i>
<i class="icon">{icon_off}</i>
{/if}
<slot></slot>
</button>

View File

@@ -4,11 +4,14 @@
export type GenericResponse = {
value: string,
message: string,
errors?: GenericResponse[],
extra?: { [index: string]: Object },
}
export type User = {
username: string,
email: string,
otp_enabled: boolean,
subscription: Subscription,
storage_space_used: number,
filesystem_storage_used: number,

8
svelte/src/login.js Normal file
View File

@@ -0,0 +1,8 @@
import App from './login/Router.svelte';
const app = new App({
target: document.getElementById("page_body"),
props: {}
});
export default app;

View File

@@ -1,43 +1,158 @@
<script>
import { createEventDispatcher } from "svelte";
import Form from "./../util/Form.svelte"
<script lang="ts">
import { createEventDispatcher, onMount } from "svelte";
import Form, { type FormConfig } from "./../util/Form.svelte"
import { check_response, get_endpoint } from "../lib/PixeldrainAPI.mjs";
let dispatch = createEventDispatcher()
let form = {
name: "login",
const form_login: FormConfig = {
fields: [
{
name: "username",
label: "Username",
label: "E-mail or username",
type: "username",
}, {
name: "password",
label: "Password",
type: "current_password",
description:
`A password is not required to log in. If your account has an
e-mail address configured you can just enter that and press
login.`
},
],
submit_label: `<i class="icon">send</i> Login`,
on_submit: async fields => {
const form = new FormData()
form.append("username", fields.username)
form.append("password", fields.password)
form.append("app_name", "website login")
const resp = await fetch(
window.api_endpoint+"/user/login",
{ method: "POST", body: form }
);
if(resp.status >= 400) {
return {error_json: await resp.json()}
}
let jresp = await resp.json()
dispatch("login", {key: jresp.auth_key})
return {success: true, message: "Successfully logged in"}
on_submit: async (fields) => {
username = fields.username
password = fields.password
return await login()
},
}
const form_otp: FormConfig = {
fields: [
{
name: "totp",
label: "One-time password",
type: "totp",
description: `Please enter the one-time password from your authenticator app`
},
],
submit_label: `<i class="icon">send</i> Login`,
on_submit: async (fields) => {
totp = fields.totp
return await login()
},
}
// The currently rendered form
let form: FormConfig = form_login
let username = ""
let password = ""
let totp = ""
// Link login
let link_login_user_id = ""
let link_login_id = ""
let login_redirect = ""
let new_email = ""
const login = async (e?: SubmitEvent) => {
if (e !== undefined) {
e.preventDefault()
}
let fd = new FormData()
fd.set("username", username)
fd.append("app_name", "website login")
if (password !== "") {
fd.set("password", password)
}
if (link_login_user_id !== "" && link_login_id !== "") {
fd.set("link_login_user_id", link_login_user_id)
fd.set("link_login_id", link_login_id)
if (new_email !== "") {
fd.set("new_email", new_email)
}
}
if (totp !== "") {
fd.set("totp", totp)
}
if (login_redirect !== "") {
fd.set("redirect", login_redirect)
}
try {
const resp = await check_response(await fetch(
get_endpoint() + "/user/login",
{method: "POST", body: fd},
))
if (resp.value !== undefined && resp.value === "login_link_sent") {
return {
success: true,
message: "A login link was sent to your e-mail address. Click it to continue logging in",
}
}
dispatch("login", {key: resp.auth_key})
if (typeof login_redirect === "string" && login_redirect.startsWith("/")) {
console.debug("redirecting user to requested path", login_redirect)
window.location.href = window.location.protocol+"//"+window.location.host+login_redirect
} else if (window.location.pathname === "/login") {
window.location.href = "/user"
}
return {success: true, message: "Successfully logged in"}
} catch (err) {
if (err.value === "otp_required") {
form = form_otp
return
} else if (err.value === "login_link_already_sent") {
return {
success: false,
message: `A login link was already recently sent to your inbox.
Please use that one before requesting a new one. You can
only have one login link at a time. Login links stay active
for 15 minutes.`
}
}
return {success: false, message: undefined, error_json: err}
}
}
onMount(() => {
const params = new URLSearchParams(document.location.search)
if (params.get("redirect") !== null) {
login_redirect = params.get("redirect")
}
if (params.get("link_login_user_id") !== null && params.get("link_login_id") !== null) {
link_login_user_id = params.get("link_login_user_id")
link_login_id = params.get("link_login_id")
if (params.get("new_email") !== null) {
new_email = params.get("new_email")
}
login()
}
})
</script>
<Form config={form}></Form>
<section>
<Form config={form}/>
<br/>
<p>
If you log in with just your e-mail address then a login link will be
sent to your inbox. Click the link to log in to your account. If the
link did not arrive, please check your spam folder. Your account needs a
verified e-mail address for this login method to work.
</p>
<p>
If you have lost your password you can use this method to log in. Please
configure a new password after logging in.
</p>
</section>

View File

@@ -22,13 +22,9 @@ let page = "login"
<div class="page_content">
{#if page === "login"}
<Login on:login={finish_login}/>
<p>
If you have lost your password, you can <a
href="password_reset">request a new password here</a>.
</p>
<Login on:login={finish_login}/>
{:else if page === "register"}
<Register on:login={finish_login}/>
<Register on:login={finish_login}/>
{/if}
</div>

View File

@@ -1,11 +1,8 @@
<script>
import { createEventDispatcher, onMount } from "svelte";
import Form from "../util/Form.svelte"
<script lang="ts">
import Form, { type FormConfig } from "../util/Form.svelte"
import { get_endpoint } from "../lib/PixeldrainAPI.mjs";
let dispatch = createEventDispatcher()
let form = {
name: "register",
let form: FormConfig = {
fields: [
{
name: "username",
@@ -34,6 +31,7 @@ let form = {
on_submit: async fields => {
if (fields.password !== fields.password2) {
return {
success: false,
error_json: {
value: "password_verification_failed",
message: "Password verification failed. Please enter the same " +
@@ -48,33 +46,17 @@ let form = {
form.append("password", fields.password)
const resp = await fetch(
window.api_endpoint+"/user/register",
get_endpoint()+"/user/register",
{ method: "POST", body: form }
);
if(resp.status >= 400) {
return {error_json: await resp.json()}
return {success: false, error_json: await resp.json()}
}
// Register successful, now we will try logging in with the same
// credentials
const login_form = new FormData()
login_form.append("username", fields.username)
login_form.append("password", fields.password)
login_form.append("app_name", "website login")
const login_resp = await fetch(
window.api_endpoint+"/user/login",
{ method: "POST", body: form }
);
if(login_resp.status >= 400) {
return {error_json: await login_resp.json()}
return {
success: true,
message: "Account registration successful. Please check your inbox for an e-mail verification link"
}
let jresp = await login_resp.json()
dispatch("login", {key: jresp.auth_key})
return {success: true, message: "Successfully registered a new account"}
},
}
</script>

View File

@@ -0,0 +1,36 @@
<script>
import TabMenu from "../util/TabMenu.svelte";
import Register from "./Register.svelte";
import Login from "./Login.svelte";
import { onMount } from "svelte";
import { get_user } from "../lib/PixeldrainAPI.mjs";
let pages = [
{
path: "/login",
title: "Login",
icon: "login",
component: Login,
}, {
path: "/register",
title: "Register",
icon: "settings",
component: Register,
},
]
onMount(async () => {
const params = new URLSearchParams(document.location.search)
const user = await get_user()
if (
params.get("link_login_user_id") === null &&
params.get("link_login_id") === null &&
user.username !== ""
) {
console.debug("User is already logged in, redirecting to user dashboard")
window.location.href = "/user"
}
})
</script>
<TabMenu pages={pages} title="Login" large_tabs />

View File

@@ -3,6 +3,7 @@ import { onMount } from "svelte";
import CopyButton from "../layout/CopyButton.svelte";
import Form from "./../util/Form.svelte";
import Button from "../layout/Button.svelte";
import OtpSetup from "./OTPSetup.svelte";
let affiliate_link = window.location.protocol+"//"+window.location.host + "?ref=" + encodeURIComponent(window.user.username)
let affiliate_deny = false
@@ -28,16 +29,13 @@ let account_settings = {
please check your spam box too. Leave the field empty to remove
your current e-mail address from your account`,
separator: true
}, {
name: "password_old",
label: "Current password",
type: "current_password",
discription: `Enter your password here if you would like to change
your password.`
}, {
name: "password_new1",
label: "New password",
type: "new_password",
description: `Enter a new password here to change your account
password. If you do not wish to change your password, leave the
field empty`
}, {
name: "password_new2",
label: "New password again",
@@ -67,7 +65,6 @@ let account_settings = {
const form = new FormData()
form.append("email", fields.email)
form.append("password_old", fields.password_old)
form.append("password_new", fields.password_new1)
form.append("username", fields.username)
@@ -114,14 +111,29 @@ let delete_account = {
name: "description",
label: "Description",
type: "description",
description: `When you delete your pixeldrain account you will be
description: `
<p>
When you delete your pixeldrain account you will be
logged out on all of your devices. Your account will be
scheduled for deletion in seven days. If you log back in to your
account during those seven days the deletion will be canceled.
<br/><br/>
</p>
<p>
The files uploaded to your account are not deleted. You need to
do that manually before deleting the account. If you do not
delete your files then they will stay available as anonymously
uploaded files and they will follow the regular file expiry
rules.
</p>
<p>
Any prepaid credit on your account will also be deleted. This is
not recoverable. We don't offer refunds on prepaid credit.
</p>
<p>
If you have an active Pro subscription you need to end that
separately through your Patreon account. Deleting your
pixeldrain account will not cancel the subscription.`,
pixeldrain account will not cancel the subscription.
</p>`,
},
],
submit_red: true,
@@ -146,6 +158,11 @@ let delete_account = {
<Form config={account_settings}></Form>
</fieldset>
<fieldset>
<legend>Two-factor authentication</legend>
<OtpSetup/>
</fieldset>
<fieldset>
<legend>Affiliate settings</legend>
<Form config={affiliate_settings}></Form>

View File

@@ -37,7 +37,6 @@ const save_embed = async () => {
onMount(() => {
embed_domains = window.user.file_embed_domains
})
</script>
<LoadingIndicator loading={loading}/>

View File

@@ -0,0 +1,169 @@
<script lang="ts">
import { onMount } from "svelte";
import Button from "../layout/Button.svelte";
import CopyButton from "../layout/CopyButton.svelte";
import ToggleButton from "../layout/ToggleButton.svelte";
import { check_response, get_endpoint, get_user, type User } from "../lib/PixeldrainAPI.mjs";
let user: User = null
let secret = ""
let uri = ""
let qr = ""
let reveal_key = false
const generate_key = async () => {
let form = new FormData()
form.set("action", "generate")
const resp = await check_response(await fetch(
get_endpoint() + "/user/totp",
{method: "POST", body: form},
))
secret = resp.secret
uri = resp.uri
qr = get_endpoint()+"/misc/qr?text=" +encodeURIComponent(resp.uri)
console.log(resp)
}
let otp = ""
const verify = async (e: SubmitEvent) => {
e.preventDefault()
let form = new FormData()
form.set("action", "verify")
form.set("otp", otp)
form.set("secret", secret)
try {
const resp = await check_response(await fetch(
get_endpoint() + "/user/totp",
{method: "POST", body: form},
))
user.otp_enabled = true
alert("Success!")
} catch (err) {
if (err.value === "otp_incorrect") {
alert(
"The entered one-time password is not valid. It may have "+
"expired. Please return to your authenticator app and retry."+
"\n\n"+
"If it still doesn't work after that then your system clock "+
"might be incorrect. Please enable time synchronization in "+
"your operating system."
)
}
}
}
const disable = async () => {
let form = new FormData()
form.set("action", "delete")
await check_response(await fetch(
get_endpoint() + "/user/totp",
{method: "POST", body: form},
))
user.otp_enabled = false
}
onMount(async () => {
user = await get_user()
})
</script>
<div class="form">
{#if user !== null && user.otp_enabled}
<p>
Two-factor authentication is enabled on your account. Your account
is secure. If you have lost your recovery keys or authenticator
device, you can disable 2FA with the button below. After disabling
2FA you can enable it again.
<br/>
<Button click={disable} icon="close" label="Disable 2FA"/>
</p>
{:else if secret === ""}
<p>
You can improve your account security by enabling two-factor
authentication. When this is enabled you will be asked to enter a
second password when logging in. This password changes periodically.
</p>
<p>
Get started by generating an OTP key:
<Button click={generate_key} label="Generate OTP key"/>
</p>
{:else}
<h4>Key created</h4>
<p>
Now enter the secret in your Authenticator app. Most password
managers support one-time passwords. A popular option for Android is
<a href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2">Google Authenticator</a>.
</p>
<p>
If your authenticator app has a QR code scanner you can scan the
code below. If not then you must enter the key manually.
</p>
<div class="qr_container">
<img src="{qr}" alt="OTP QR code" class="qr"/>
<div class="copy_container">
<Button click={() => window.location.href = uri} icon="key" label="Open in Authenticator app"/>
<CopyButton text={secret}>Copy secret key to clipboard</CopyButton>
<ToggleButton bind:on={reveal_key} icon_on="visibility" icon_off="visibility_off">Reveal secret key</ToggleButton>
{#if reveal_key}
<div class="key highlight_border">
{secret}
</div>
{/if}
</div>
</div>
<p>
Please save the secret key in your password manager or another safe
place. If you lose your authenticator app then the secret key is the
only way to gain access to your account.
</p>
<p>
Now enter the generated password to verify that the authenticator
app is working properly. This step enables two-factor authentication
on your account.
</p>
<form id="otp_verify" on:submit={verify} class="otp_form">
<input bind:value={otp} type="text" autocomplete="one-time-code" pattern={"[0-9]{6}"} required>
<Button form="otp_verify" label="Verify OTP"/>
</form>
{/if}
</div>
<style>
.qr_container {
display: flex;
flex-direction: row;
justify-content: space-around;
gap: 8px;
}
@media(max-width: 500px) {
.qr_container {
flex-direction: column;
align-items: center;
}
}
.qr {
flex: 1 1 auto;
max-width: 250px;
}
.copy_container {
flex: 1 1 auto;
display: flex;
flex-direction: column;
}
.key {
line-break: anywhere;
}
.otp_form {
display: flex;
flex-direction: row;
}
.otp_form > input {
flex: 1 1 auto;
}
</style>

View File

@@ -1,22 +1,34 @@
<script>
<script lang="ts" context="module">
export type FormConfig = {
fields: FormField[],
submit_label: string
submit_red?: boolean,
on_submit: (values: {[key: string]: string}) => Promise<SubmitResult>,
}
export type FormField = {
type: string,
name?: string,
label?: string,
default_value?: string,
description?: string,
separator?: boolean,
radio_values?: string[], // Options to choose from when type is "radio"
pattern?: string, // Used for pattern matching on input fields
binding?: any
}
export type SubmitResult = {
success: boolean,
message?: string,
messages?: string[],
error_json?: GenericResponse,
}
</script>
<script lang="ts">
import { onMount } from "svelte";
import Spinner from "./Spinner.svelte";
import type { GenericResponse } from "../lib/PixeldrainAPI.mjs";
export let config = {
fields: [
{
name: "",
label: "",
type: "",
default_value: "",
binding: null,
}
],
submit_label: "",
submit_red: false,
on_submit: async field_values => {},
}
export let config: FormConfig
onMount(() => {
config.fields.forEach(field => {
@@ -28,15 +40,9 @@ onMount(() => {
let loading = false
let submitted = false
let submit_result = {
success: false,
message: "",
messages: null,
}
let submit_result: SubmitResult
let form_elem
let submit = async (event) => {
const submit = async (event: SubmitEvent) => {
loading = true
event.preventDefault()
@@ -51,15 +57,13 @@ let submit = async (event) => {
}
} else if (field.type === "description") {
field_values[field.name] = ""
} else if (field.type === "captcha") {
field_values[field.name] = form_elem.getElementsByClassName("g-recaptcha-response")[0].value
} else {
field_values[field.name] = field.binding.value
}
})
submit_result = await config.on_submit(field_values)
if (submit_result.error_json) {
if (submit_result && submit_result.error_json) {
submit_result = handle_errors(submit_result.error_json)
}
submitted = true
@@ -67,7 +71,8 @@ let submit = async (event) => {
loading = false
return false
}
let field_label = (field) => {
const field_label = (field: string) => {
let label = ""
config.fields.forEach(val => {
if (val.name === field) {
@@ -76,7 +81,8 @@ let field_label = (field) => {
})
return label
}
let handle_errors = (response) => {
const handle_errors = (response: GenericResponse) => {
console.log(response)
let result = {success: false, message: "", messages: null}
@@ -86,18 +92,18 @@ let handle_errors = (response) => {
response.errors.forEach(err => {
if (err.value === "string_out_of_range") {
result.messages.push(
`${field_label(err.extra.field)} is too long or too short.
`${field_label(<string>err.extra.field)} is too long or too short.
It should be between ${err.extra.min_len} and
${err.extra.max_len} characters. Current length:
${err.extra.len}`
)
} else if (err.value === "field_contains_illegal_character") {
result.messages.push(
`Character '${err.extra.char}' is not allowed in ${field_label(err.extra.field)}`
`Character '${err.extra.char}' is not allowed in ${field_label(<string>err.extra.field)}`
)
} else if (err.value === "missing_field") {
result.messages.push(
`${field_label(err.extra.field)} is required`
`${field_label(<string>err.extra.field)} is required`
)
} else {
result.messages.push(err.message)
@@ -111,8 +117,8 @@ let handle_errors = (response) => {
}
</script>
<form method="POST" on:submit={submit} bind:this={form_elem}>
{#if submitted}
<form method="POST" on:submit={submit}>
{#if submitted && submit_result !== undefined}
{#if submit_result.messages}
<div id="submit_result" class:highlight_green={submit_result.success} class:highlight_red={!submit_result.success}>
Something went wrong, please correct these errors before continuing:<br/>
@@ -129,7 +135,6 @@ let handle_errors = (response) => {
{/if}
{/if}
<div class="form">
{#each config.fields as field}
{#if field.type !== "description"}
@@ -141,8 +146,10 @@ let handle_errors = (response) => {
id="input_{field.name}"
name="{field.name}"
value="{field.default_value}"
pattern={field.pattern}
type="text"
class="form_input"/>
class="form_input"
/>
{:else if field.type === "text_area"}
<textarea bind:this={field.binding}
id="input_{field.name}"
@@ -155,58 +162,79 @@ let handle_errors = (response) => {
id="input_{field.name}"
name="{field.name}"
value="{field.default_value}"
pattern={field.pattern}
type="number"
class="form_input"/>
class="form_input"
/>
{:else if field.type === "decimal"}
<input bind:this={field.binding}
id="input_{field.name}"
name="{field.name}"
value="{field.default_value}"
pattern={field.pattern}
type="number"
step="0.1"
class="form_input"/>
class="form_input"
/>
{:else if field.type === "datetime-local"}
<input bind:this={field.binding}
id="input_{field.name}"
name="{field.name}"
value="{field.default_value}"
pattern={field.pattern}
type="datetime-local"
class="form_input"/>
class="form_input"
/>
{:else if field.type === "username"}
<input bind:this={field.binding}
id="input_{field.name}"
name="{field.name}"
value="{field.default_value}"
pattern={field.pattern}
type="text"
autocomplete="username"
class="form_input"/>
class="form_input"
/>
{:else if field.type === "email"}
<input bind:this={field.binding}
id="input_{field.name}"
name="{field.name}"
value="{field.default_value}"
pattern={field.pattern}
type="email"
autocomplete="email"
class="form_input"/>
class="form_input"
/>
{:else if field.type === "current_password"}
<input bind:this={field.binding}
id="input_{field.name}"
name="{field.name}"
value="{field.default_value}"
pattern={field.pattern}
type="password"
autocomplete="current-password"
class="form_input"/>
class="form_input"
/>
{:else if field.type === "new_password"}
<input bind:this={field.binding}
id="input_{field.name}"
name="{field.name}"
value="{field.default_value}"
pattern={field.pattern}
type="password"
autocomplete="new-password"
class="form_input"/>
{:else if field.type === "captcha"}
<script src="https://www.google.com/recaptcha/api.js" async defer></script>
<div class="g-recaptcha" data-theme="dark" data-sitekey="{field.captcha_site_key}"></div>
class="form_input"
/>
{:else if field.type === "totp"}
<input bind:this={field.binding}
id="input_{field.name}"
name="{field.name}"
value="{field.default_value?field.default_value:""}"
type="text"
autocomplete="one-time-code"
pattern={"[0-9]{6}"}
class="form_input"
/>
{:else if field.type === "radio"}
<div>
{#each field.radio_values as val}