merge newui branch
This commit is contained in:
129
ui/src/auth/pageConfirmEmailChange.js
Normal file
129
ui/src/auth/pageConfirmEmailChange.js
Normal file
@@ -0,0 +1,129 @@
|
||||
import PocketBase, { getTokenPayload } from "pocketbase";
|
||||
|
||||
export function pageConfirmEmailChange(route) {
|
||||
const token = route.params?.token || "";
|
||||
const tokenPayload = getTokenPayload(token);
|
||||
|
||||
if (!tokenPayload.newEmail || !tokenPayload.collectionId) {
|
||||
app.toasts.error("Invalid or expired email change token.");
|
||||
window.location.hash = "#/";
|
||||
return;
|
||||
}
|
||||
|
||||
app.store.title = "Confirm email change";
|
||||
|
||||
const data = store({
|
||||
password: "",
|
||||
isSubmitting: false,
|
||||
isSuccess: false,
|
||||
showPassword: false,
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
if (data.isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
data.isSubmitting = true;
|
||||
|
||||
// init a custom client to avoid interfering with the superuser state
|
||||
const client = new PocketBase(app.pb.baseURL);
|
||||
|
||||
try {
|
||||
await client.collection(tokenPayload.collectionId).confirmEmailChange(token, data.password);
|
||||
data.isSuccess = true;
|
||||
} catch (err) {
|
||||
app.checkApiError(err);
|
||||
data.isSuccess = false;
|
||||
}
|
||||
|
||||
data.isSubmitting = false;
|
||||
}
|
||||
|
||||
return t.div(
|
||||
{
|
||||
pbEvent: "pageConfirmEmailChange",
|
||||
className: "wrapper sm m-auto p-b-base",
|
||||
},
|
||||
t.header(
|
||||
{ className: "txt-center m-b-base" },
|
||||
t.img({ className: "main-logo", src: () => app.store.mainLogo, ariaHidden: true, alt: "App logo" }),
|
||||
t.h5({ className: "m-t-10" }, () => app.store.title),
|
||||
),
|
||||
() => {
|
||||
if (data.isSuccess) {
|
||||
return t.div(
|
||||
{
|
||||
pbEvent: "confirmEmailChangeAlert",
|
||||
className: "alert success txt-center",
|
||||
},
|
||||
t.p(null, "The email was successfully changed."),
|
||||
t.p(null, "You can go back and sign in with your new email address."),
|
||||
);
|
||||
}
|
||||
|
||||
return t.form(
|
||||
{
|
||||
pbEvent: "confirmEmailChangeForm",
|
||||
className: "grid confirm-email-change-form",
|
||||
onsubmit: (e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
},
|
||||
},
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.div(
|
||||
{ className: "content txt-center m-b-sm" },
|
||||
"Type your password to confirm changing your email address to ",
|
||||
t.strong(null, tokenPayload.newEmail),
|
||||
":",
|
||||
),
|
||||
t.div(
|
||||
{ className: "fields" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: "password_confirm" }, "Password"),
|
||||
t.input({
|
||||
id: "password_confirm",
|
||||
name: "password",
|
||||
required: true,
|
||||
autofocus: true,
|
||||
type: () => (data.showPassword ? "text" : "password"),
|
||||
value: () => data.password,
|
||||
oninput: (e) => (data.password = e.target.value),
|
||||
}),
|
||||
),
|
||||
t.div(
|
||||
{ className: "field addon" },
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
tabIndex: -1,
|
||||
className: "btn sm transparent secondary circle tooltip-right",
|
||||
ariaDescription: app.attrs.tooltip(() =>
|
||||
data.showPassword ? "Hide password" : "Show password"
|
||||
),
|
||||
onclick: () => (data.showPassword = !data.showPassword),
|
||||
},
|
||||
t.i({
|
||||
className: () => (data.showPassword ? "ri-eye-off-line" : "ri-eye-line"),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.button(
|
||||
{
|
||||
className: () => `btn lg block ${data.isSubmitting ? "loading" : ""}`,
|
||||
disabled: () => data.isSubmitting,
|
||||
},
|
||||
t.span({ className: "txt" }, "Confirm new email"),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
167
ui/src/auth/pageConfirmPasswordReset.js
Normal file
167
ui/src/auth/pageConfirmPasswordReset.js
Normal file
@@ -0,0 +1,167 @@
|
||||
import PocketBase, { getTokenPayload } from "pocketbase";
|
||||
|
||||
export function pageConfirmPasswordReset(route) {
|
||||
const token = route.params?.token || "";
|
||||
const tokenPayload = getTokenPayload(token);
|
||||
|
||||
if (!tokenPayload.email || !tokenPayload.collectionId) {
|
||||
app.toasts.error("Invalid or expired password reset token.");
|
||||
window.location.hash = "#/";
|
||||
return;
|
||||
}
|
||||
|
||||
app.store.title = "Confirm password reset";
|
||||
|
||||
const data = store({
|
||||
newPassword: "",
|
||||
newPasswordConfirm: "",
|
||||
showNewPassword: false,
|
||||
showNewPasswordConfirm: false,
|
||||
isSubmitting: false,
|
||||
isSuccess: false,
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
if (data.isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
data.isSubmitting = true;
|
||||
|
||||
// init a custom client to avoid interfering with the superuser state
|
||||
const client = new PocketBase(app.pb.baseURL);
|
||||
|
||||
try {
|
||||
await client
|
||||
.collection(tokenPayload.collectionId)
|
||||
.confirmPasswordReset(token, data.newPassword, data.newPasswordConfirm);
|
||||
|
||||
data.isSuccess = true;
|
||||
} catch (err) {
|
||||
app.checkApiError(err);
|
||||
}
|
||||
|
||||
data.isSubmitting = false;
|
||||
}
|
||||
|
||||
return t.div(
|
||||
{
|
||||
pbEvent: "pageConfirmPasswordReset",
|
||||
className: "wrapper sm m-auto p-b-base",
|
||||
},
|
||||
t.header(
|
||||
{ className: "txt-center m-b-base" },
|
||||
t.img({ className: "main-logo", src: () => app.store.mainLogo, ariaHidden: true, alt: "App logo" }),
|
||||
t.h5({ className: "m-t-10" }, () => app.store.title),
|
||||
),
|
||||
() => {
|
||||
if (data.isSuccess) {
|
||||
return t.div(
|
||||
{ pbEvent: "confirmPasswordResetAlert", className: "alert success txt-center" },
|
||||
t.p(null, "The password was successfully changed."),
|
||||
t.p(null, "You can go back to sign in with your new password."),
|
||||
);
|
||||
}
|
||||
|
||||
return t.form(
|
||||
{
|
||||
pbEvent: "confirmPasswordResetForm",
|
||||
className: "grid confirm-password-reset-form",
|
||||
onsubmit: (e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
},
|
||||
},
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.div(
|
||||
{ className: "content txt-center m-b-sm" },
|
||||
"Type your new password for ",
|
||||
t.strong(null, tokenPayload.email),
|
||||
":",
|
||||
),
|
||||
t.div(
|
||||
{ className: "fields" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: "newPassword" }, "New password"),
|
||||
t.input({
|
||||
id: "newPassword",
|
||||
name: "password",
|
||||
required: true,
|
||||
autofocus: true,
|
||||
autocomplete: "new-password",
|
||||
type: () => (data.showNewPassword ? "text" : "password"),
|
||||
value: () => data.newPassword,
|
||||
oninput: (e) => (data.newPassword = e.target.value),
|
||||
}),
|
||||
),
|
||||
t.div(
|
||||
{ className: "field addon" },
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
tabIndex: -1,
|
||||
className: "btn sm transparent secondary circle tooltip-right",
|
||||
ariaDescription: app.attrs.tooltip(() =>
|
||||
data.showNewPassword ? "Hide password" : "Show password"
|
||||
),
|
||||
onclick: () => (data.showNewPassword = !data.showNewPassword),
|
||||
},
|
||||
t.i({
|
||||
className: () => (data.showNewPassword ? "ri-eye-off-line" : "ri-eye-line"),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.div(
|
||||
{ className: "fields" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: "newPasswordConfirm" }, "New password confirm"),
|
||||
t.input({
|
||||
id: "newPasswordConfirm",
|
||||
name: "passwordConfirm",
|
||||
required: true,
|
||||
autocomplete: "new-password",
|
||||
type: () => (data.showNewPasswordConfirm ? "text" : "password"),
|
||||
value: () => data.newPasswordConfirm,
|
||||
oninput: (e) => (data.newPasswordConfirm = e.target.value),
|
||||
}),
|
||||
),
|
||||
t.div(
|
||||
{ className: "field addon" },
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
tabIndex: -1,
|
||||
className: "btn sm transparent secondary circle tooltip-right",
|
||||
ariaDescription: app.attrs.tooltip(() =>
|
||||
data.showNewPasswordConfirm ? "Hide password" : "Show password"
|
||||
),
|
||||
onclick: () => (data.showNewPasswordConfirm = !data.showNewPasswordConfirm),
|
||||
},
|
||||
t.i({
|
||||
className: () => (data.showNewPasswordConfirm ? "ri-eye-off-line" : "ri-eye-line"),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.button(
|
||||
{
|
||||
className: () => `btn lg block ${data.isSubmitting ? "loading" : ""}`,
|
||||
disabled: () => data.isSubmitting,
|
||||
},
|
||||
t.span({ className: "txt" }, "Set new password"),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
112
ui/src/auth/pageConfirmVerification.js
Normal file
112
ui/src/auth/pageConfirmVerification.js
Normal file
@@ -0,0 +1,112 @@
|
||||
import PocketBase, { getTokenPayload } from "pocketbase";
|
||||
|
||||
export function pageConfirmVerification(route) {
|
||||
const token = route.params?.token || "";
|
||||
const tokenPayload = getTokenPayload(token);
|
||||
|
||||
if (!tokenPayload.email || !tokenPayload.collectionId) {
|
||||
app.toasts.error("Invalid or expired verification token.");
|
||||
window.location.hash = "#/";
|
||||
return;
|
||||
}
|
||||
|
||||
app.store.title = "Confirm verification";
|
||||
|
||||
const data = store({
|
||||
isConfirming: false,
|
||||
isConfirmSuccess: false,
|
||||
// ---
|
||||
isResending: false,
|
||||
isResendSuccess: false,
|
||||
});
|
||||
|
||||
confirm();
|
||||
|
||||
async function confirm() {
|
||||
if (data.isConfirming) {
|
||||
return;
|
||||
}
|
||||
|
||||
data.isConfirming = true;
|
||||
|
||||
// init a custom client to avoid interfering with the superuser state
|
||||
const client = new PocketBase(app.pb.baseURL);
|
||||
|
||||
try {
|
||||
await client.collection(tokenPayload.collectionId).confirmVerification(token);
|
||||
data.isConfirmSuccess = true;
|
||||
} catch (err) {
|
||||
data.isConfirmSuccess = false;
|
||||
}
|
||||
|
||||
data.isConfirming = false;
|
||||
}
|
||||
|
||||
async function resend() {
|
||||
if (data.isResending) {
|
||||
return;
|
||||
}
|
||||
|
||||
data.isResending = true;
|
||||
|
||||
// init a custom client to avoid interfering with the superuser state
|
||||
const client = new PocketBase(import.meta.env.PB_BACKEND_URL);
|
||||
|
||||
try {
|
||||
await client.collection(tokenPayload.collectionId).requestVerification(tokenPayload.email);
|
||||
data.isResendSuccess = true;
|
||||
} catch (err) {
|
||||
app.checkApiError(err);
|
||||
data.isResendSuccess = false;
|
||||
}
|
||||
|
||||
data.isResending = false;
|
||||
}
|
||||
|
||||
return t.div(
|
||||
{
|
||||
pbEvent: "pageConfirmVerification",
|
||||
className: "wrapper sm m-auto p-b-base",
|
||||
},
|
||||
t.header(
|
||||
{ className: "txt-center m-b-base" },
|
||||
t.img({ className: "main-logo", src: () => app.store.mainLogo, ariaHidden: true, alt: "App logo" }),
|
||||
t.h5({ className: "m-t-10" }, () => app.store.title),
|
||||
),
|
||||
() => {
|
||||
if (data.isConfirming) {
|
||||
return t.div({ className: "block txt-center" }, t.span({ className: "loader" }, "Please wait..."));
|
||||
}
|
||||
|
||||
if (data.isConfirmSuccess) {
|
||||
return t.div(
|
||||
{ pbEvent: "confirmVerificationSuccessAlert", className: "alert success txt-center" },
|
||||
t.p(null, "Successfully verified ", t.strong(null, tokenPayload.email), "."),
|
||||
);
|
||||
}
|
||||
|
||||
if (data.isResendSuccess) {
|
||||
return t.div(
|
||||
{ pbEvent: "confirmVerificationResendAlert", className: "alert success txt-center" },
|
||||
t.p(null, "Please check your email for the new verification link."),
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
t.div(
|
||||
{ pbEvent: "confirmVerificationErrorAlert", className: "alert danger txt-center m-b-base" },
|
||||
t.p(null, "Invalid or expired verification token."),
|
||||
),
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: () => `btn transparent lg block ${data.isResending ? "loading" : ""}`,
|
||||
disabled: () => data.isResending,
|
||||
onclick: () => resend(),
|
||||
},
|
||||
t.span({ className: "txt" }, "Resend"),
|
||||
),
|
||||
];
|
||||
},
|
||||
);
|
||||
}
|
||||
266
ui/src/auth/pageInstaller.js
Normal file
266
ui/src/auth/pageInstaller.js
Normal file
@@ -0,0 +1,266 @@
|
||||
import { getTokenPayload, isTokenExpired } from "pocketbase";
|
||||
|
||||
export function pageInstaller(route) {
|
||||
const token = route.params?.token || "";
|
||||
const tokenPayload = getTokenPayload(token);
|
||||
|
||||
if (tokenPayload.type != "auth" || isTokenExpired(token)) {
|
||||
app.toasts.error("The installer token is invalid or has expired.");
|
||||
window.location.hash = "#/";
|
||||
return;
|
||||
}
|
||||
|
||||
app.store.title = "Setup your PocketBase instance";
|
||||
|
||||
const data = store({
|
||||
email: "",
|
||||
password: "",
|
||||
passwordConfirm: "",
|
||||
showPassword: false,
|
||||
showPasswordConfirm: false,
|
||||
isSubmitting: false,
|
||||
isUploading: false,
|
||||
get isBusy() {
|
||||
return data.isSubmitting || data.isUploading;
|
||||
},
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
if (data.isBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
data.isSubmitting = true;
|
||||
|
||||
try {
|
||||
await app.pb.collection("_superusers").create(
|
||||
{
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
passwordConfirm: data.passwordConfirm,
|
||||
},
|
||||
{
|
||||
headers: { Authorization: token },
|
||||
},
|
||||
);
|
||||
|
||||
await app.pb.collection("_superusers").authWithPassword(data.email, data.password);
|
||||
|
||||
window.location.hash = "#/";
|
||||
} catch (err) {
|
||||
app.checkApiError(err);
|
||||
}
|
||||
|
||||
data.isSubmitting = false;
|
||||
}
|
||||
|
||||
const fileInputId = "backupFileInput";
|
||||
|
||||
function resetSelectedBackupFile() {
|
||||
const input = document.getElementById(fileInputId);
|
||||
if (input) {
|
||||
input.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
function uploadBackupConfirm(file) {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
app.modals.confirm(
|
||||
t.h6(
|
||||
null,
|
||||
`Note that we don't perform validations for the uploaded backup files. Proceed with caution and only if you trust the file source.\n\n`
|
||||
+ `Do you really want to upload and initialize "${file.name}"?`,
|
||||
),
|
||||
() => {
|
||||
uploadBackup(file);
|
||||
},
|
||||
() => {
|
||||
resetSelectedBackupFile();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function uploadBackup(file) {
|
||||
if (!file || data.isBusy) {
|
||||
return;
|
||||
}
|
||||
|
||||
data.isUploading = true;
|
||||
|
||||
try {
|
||||
await app.pb.backups.upload(
|
||||
{ file: file },
|
||||
{
|
||||
headers: { Authorization: token },
|
||||
},
|
||||
);
|
||||
|
||||
await app.pb.backups.restore(file.name, {
|
||||
headers: { Authorization: token },
|
||||
});
|
||||
|
||||
app.toasts.info("Please wait while extracting the uploaded archive!");
|
||||
|
||||
// optimistic restore completion
|
||||
await new Promise((r) => setTimeout(r, 3000));
|
||||
|
||||
window.location.href = "#/";
|
||||
} catch (err) {
|
||||
app.checkApiError(err);
|
||||
}
|
||||
|
||||
resetSelectedBackupFile();
|
||||
|
||||
data.isUploading = false;
|
||||
}
|
||||
|
||||
return t.div(
|
||||
{
|
||||
pbEvent: "pageInstaller",
|
||||
className: "wrapper sm m-auto p-b-base",
|
||||
},
|
||||
t.header(
|
||||
{ className: "txt-center m-b-base" },
|
||||
t.img({ className: "main-logo", src: () => app.store.mainLogo, ariaHidden: true, alt: "App logo" }),
|
||||
t.h5({ className: "m-t-10" }, () => app.store.title),
|
||||
),
|
||||
t.form(
|
||||
{
|
||||
pbEvent: "installerForm",
|
||||
className: "grid installer-form",
|
||||
onsubmit: (e) => {
|
||||
e.preventDefault();
|
||||
submit(data);
|
||||
},
|
||||
},
|
||||
t.div({ className: "col-12 txt-center" }, "Create your first superuser account in order to continue:"),
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: "superuser_email" }, "Email"),
|
||||
t.input({
|
||||
id: "superuser_email",
|
||||
name: "email",
|
||||
type: "email",
|
||||
required: true,
|
||||
autofocus: true,
|
||||
autocomplete: "off",
|
||||
disabled: () => data.isBusy,
|
||||
value: () => data.email,
|
||||
oninput: (e) => (data.email = e.target.value),
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.div(
|
||||
{ className: "fields" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: "superuser_password" }, "Password"),
|
||||
t.input({
|
||||
id: "superuser_password",
|
||||
name: "password",
|
||||
min: 10,
|
||||
required: true,
|
||||
disabled: () => data.isBusy,
|
||||
type: () => (data.showPassword ? "text" : "password"),
|
||||
value: () => data.password,
|
||||
oninput: (e) => (data.password = e.target.value),
|
||||
}),
|
||||
),
|
||||
t.div(
|
||||
{ className: "field addon" },
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
tabIndex: -1,
|
||||
className: "btn sm transparent secondary circle tooltip-right",
|
||||
ariaDescription: app.attrs.tooltip(() =>
|
||||
data.showPassword ? "Hide password" : "Show password"
|
||||
),
|
||||
onclick: () => (data.showPassword = !data.showPassword),
|
||||
},
|
||||
t.i({
|
||||
className: () => (data.showPassword ? "ri-eye-off-line" : "ri-eye-line"),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
t.div({ className: "field-help" }, "Recommended at least 10 characters."),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.div(
|
||||
{ className: "fields" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: "superuser_password_confirm" }, "Password confirm"),
|
||||
t.input({
|
||||
id: "superuser_password_confirm",
|
||||
name: "passwordConfirm",
|
||||
required: true,
|
||||
disabled: () => data.isBusy,
|
||||
type: () => (data.showPasswordConfirm ? "text" : "password"),
|
||||
value: () => data.passwordConfirm,
|
||||
oninput: (e) => (data.passwordConfirm = e.target.value),
|
||||
}),
|
||||
),
|
||||
t.div(
|
||||
{ className: "field addon" },
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
tabIndex: -1,
|
||||
className: "btn sm transparent secondary circle tooltip-right",
|
||||
ariaDescription: app.attrs.tooltip(() =>
|
||||
data.showPasswordConfirm ? "Hide password" : "Show password"
|
||||
),
|
||||
onclick: () => (data.showPasswordConfirm = !data.showPasswordConfirm),
|
||||
},
|
||||
t.i({
|
||||
className: () => (data.showPasswordConfirm ? "ri-eye-off-line" : "ri-eye-line"),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.button(
|
||||
{
|
||||
className: () => `btn lg next block ${data.isSubmitting ? "loading" : ""}`,
|
||||
disabled: () => data.isBusy,
|
||||
},
|
||||
t.span({ className: "txt" }, "Create superuser and login"),
|
||||
t.i({ className: "ri-arrow-right-line" }),
|
||||
),
|
||||
),
|
||||
),
|
||||
t.hr(),
|
||||
t.label(
|
||||
{
|
||||
htmlFor: fileInputId,
|
||||
className: () =>
|
||||
`btn secondary transparent lg block ${data.isBusy ? "disabled" : ""} ${
|
||||
data.isUploading ? "loading" : ""
|
||||
}`,
|
||||
},
|
||||
t.i({ className: "ri-upload-cloud-line" }),
|
||||
t.span({ className: "txt" }, "Or initialize from backup"),
|
||||
),
|
||||
t.input({
|
||||
id: fileInputId,
|
||||
type: "file",
|
||||
className: "hidden",
|
||||
accept: ".zip",
|
||||
onchange: (e) => {
|
||||
uploadBackupConfirm(e.target?.files?.[0]);
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
20
ui/src/auth/pageOAuth2RedirectFailure.js
Normal file
20
ui/src/auth/pageOAuth2RedirectFailure.js
Normal file
@@ -0,0 +1,20 @@
|
||||
export function pageOAuth2RedirectFailure(route) {
|
||||
app.store.title = "OAuth2 auth failed";
|
||||
|
||||
window.close();
|
||||
|
||||
return t.div(
|
||||
{ pbEvent: "pageOAuth2RedirectFailure", className: "page" },
|
||||
t.div(
|
||||
{ className: "page-content" },
|
||||
t.header(
|
||||
{ className: "txt-center p-base" },
|
||||
t.h3({ className: "primary-heading m-b-sm" }, "Auth failed."),
|
||||
t.h6(
|
||||
{ className: "secondary-heading" },
|
||||
"You can close this window and go back to the app to try again.",
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
17
ui/src/auth/pageOAuth2RedirectSuccess.js
Normal file
17
ui/src/auth/pageOAuth2RedirectSuccess.js
Normal file
@@ -0,0 +1,17 @@
|
||||
export function pageOAuth2RedirectSuccess(route) {
|
||||
app.store.title = "OAuth2 auth completed";
|
||||
|
||||
window.close();
|
||||
|
||||
return t.div(
|
||||
{ pbEvent: "pageOAuth2RedirectSuccess", className: "page" },
|
||||
t.div(
|
||||
{ className: "page-content" },
|
||||
t.header(
|
||||
{ className: "txt-center p-base" },
|
||||
t.h3({ className: "primary-heading m-b-sm" }, "Auth completed."),
|
||||
t.h6({ className: "secondary-heading" }, "You can close this window and go back to the app."),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
92
ui/src/auth/pageRequestSuperuserPasswordReset.js
Normal file
92
ui/src/auth/pageRequestSuperuserPasswordReset.js
Normal file
@@ -0,0 +1,92 @@
|
||||
export function pageRequestSuperuserPasswordReset(route) {
|
||||
app.store.title = "Forgotten superuser password";
|
||||
|
||||
const data = store({
|
||||
email: "",
|
||||
isSubmitting: false,
|
||||
success: false,
|
||||
});
|
||||
|
||||
async function submit() {
|
||||
if (data.isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
data.isSubmitting = true;
|
||||
|
||||
try {
|
||||
await app.pb.collection("_superusers").requestPasswordReset(data.email);
|
||||
data.success = true;
|
||||
} catch (err) {
|
||||
app.checkApiError(err);
|
||||
}
|
||||
|
||||
data.isSubmitting = false;
|
||||
}
|
||||
|
||||
return t.div(
|
||||
{
|
||||
pbEvent: "pageSuperuserPasswordReset",
|
||||
className: "wrapper sm m-auto p-b-base",
|
||||
},
|
||||
t.header(
|
||||
{ className: "txt-center m-b-base" },
|
||||
t.img({ className: "main-logo", src: () => app.store.mainLogo, ariaHidden: true, alt: "App logo" }),
|
||||
t.h5({ className: "m-t-10" }, () => app.store.title),
|
||||
),
|
||||
() => {
|
||||
if (data.success) {
|
||||
return t.div(
|
||||
{ pbEvent: "superuserPasswordResetAlert", className: "alert success txt-center" },
|
||||
t.p(null, "Check ", t.strong(null, data.email), " for the recovery link!"),
|
||||
);
|
||||
}
|
||||
|
||||
return t.form(
|
||||
{
|
||||
pbEvent: "superuserPasswordResetForm",
|
||||
className: "grid request-password-reset-form",
|
||||
onsubmit: (e) => {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
},
|
||||
},
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.div(
|
||||
{ className: "content txt-center m-b-sm" },
|
||||
t.p(null, "Enter the email associated with your account and we'll send you a recovery link:"),
|
||||
),
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: "password_reset_email" }, "Email"),
|
||||
t.input({
|
||||
id: "password_reset_email",
|
||||
name: "email",
|
||||
type: "email",
|
||||
required: true,
|
||||
autofocus: true,
|
||||
value: () => data.email,
|
||||
oninput: (e) => (data.email = e.target.value),
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.button(
|
||||
{
|
||||
className: () => `btn lg block ${data.isSubmitting ? "loading" : ""}`,
|
||||
disabled: () => data.isSubmitting,
|
||||
},
|
||||
t.i({ className: "ri-mail-send-line" }),
|
||||
t.span({ className: "txt" }, "Send recovery link"),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
t.div(
|
||||
{ className: "block m-t-sm txt-center" },
|
||||
t.a({ href: "#/login", className: "link-hint" }, "Back to login"),
|
||||
),
|
||||
);
|
||||
}
|
||||
420
ui/src/auth/pageSuperuserLogin.js
Normal file
420
ui/src/auth/pageSuperuserLogin.js
Normal file
@@ -0,0 +1,420 @@
|
||||
export function pageSuperuserLogin(route) {
|
||||
app.store.title = "Superuser login";
|
||||
|
||||
const data = store({
|
||||
authMethods: {},
|
||||
|
||||
identity: route.query.demoEmail?.[0] || "",
|
||||
password: route.query.demoPassword?.[0] || "",
|
||||
showPassword: false,
|
||||
|
||||
otpId: "",
|
||||
lastOTPId: "",
|
||||
otpEmail: "",
|
||||
otpPassword: "",
|
||||
|
||||
mfaId: "",
|
||||
totalSteps: 1,
|
||||
get currentStep() {
|
||||
return 1 + !!data.mfaId + !!data.otpId;
|
||||
},
|
||||
|
||||
isAuthMethodsLoading: true,
|
||||
isPasswordAuthSubmitting: false,
|
||||
isOTPRequestSubmitting: false,
|
||||
isOTPAuthSubmitting: false,
|
||||
});
|
||||
|
||||
async function loadAuthMethods() {
|
||||
data.isAuthMethodsLoading = true;
|
||||
|
||||
try {
|
||||
data.authMethods = await app.pb.collection("_superusers").listAuthMethods();
|
||||
|
||||
data.totalSteps = 1;
|
||||
data.mfaId = "";
|
||||
data.otpId = "";
|
||||
|
||||
if (data.authMethods.mfa?.enabled && data.authMethods.otp?.enabled) {
|
||||
data.totalSteps += 2; // otp request + auth
|
||||
}
|
||||
} catch (err) {
|
||||
app.checkApiError(err);
|
||||
}
|
||||
|
||||
data.isAuthMethodsLoading = false;
|
||||
}
|
||||
|
||||
loadAuthMethods();
|
||||
|
||||
return t.div(
|
||||
{
|
||||
pbEvent: "pageSuperuserLogin",
|
||||
className: "wrapper sm m-auto p-b-base",
|
||||
},
|
||||
t.header(
|
||||
{ className: "txt-center m-b-base" },
|
||||
t.img({ className: "main-logo", src: () => app.store.mainLogo, ariaHidden: true, alt: "App logo" }),
|
||||
t.h5(
|
||||
{ className: "m-t-10" },
|
||||
t.span(null, () => app.store.title),
|
||||
() => {
|
||||
if (data.totalSteps > 1) {
|
||||
return t.span(null, () => ` (${data.currentStep}/${data.totalSteps})`);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
() => {
|
||||
if (data.isAuthMethodsLoading) {
|
||||
return t.div({ className: "block txt-center" }, t.span({ className: "loader lg" }));
|
||||
}
|
||||
|
||||
if (data.authMethods.password?.enabled && !data.mfaId) {
|
||||
return authWithPasswordForm(data);
|
||||
}
|
||||
|
||||
if (data.authMethods.otp?.enabled) {
|
||||
if (!data.otpId) {
|
||||
return requestOTPForm(data);
|
||||
}
|
||||
|
||||
return authWithOTPForm(data);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Auth with password
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
async function authWithPassword(data) {
|
||||
if (data.isPasswordAuthSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
data.isPasswordAuthSubmitting = true;
|
||||
|
||||
try {
|
||||
await app.pb.collection("_superusers").authWithPassword(data.identity, data.password);
|
||||
app.toasts.removeAll();
|
||||
app.store.errors = null;
|
||||
app.utils.toRememberedPath();
|
||||
} catch (err) {
|
||||
if (err.status == 401) {
|
||||
data.mfaId = err.response.mfaId;
|
||||
|
||||
// show the otp forms
|
||||
if (
|
||||
// if the identity field is just the email use it to directly send an otp request
|
||||
data.authMethods?.password?.identityFields?.length == 1
|
||||
&& data.authMethods.password.identityFields[0] == "email"
|
||||
) {
|
||||
// prefill and request
|
||||
data.otpEmail = data.identity;
|
||||
await requestOTP(data);
|
||||
} else if (/^[^@\s]+@[^@\s]+$/.test(data.identity)) {
|
||||
// only prefill
|
||||
data.otpEmail = data.identity;
|
||||
}
|
||||
} else if (err.status != 400) {
|
||||
app.checkApiError(err);
|
||||
} else {
|
||||
app.toasts.error("Invalid login credentials.");
|
||||
}
|
||||
}
|
||||
|
||||
data.isPasswordAuthSubmitting = false;
|
||||
}
|
||||
|
||||
function authWithPasswordForm(data) {
|
||||
return t.form(
|
||||
{
|
||||
pbEvent: "authWithPasswordForm",
|
||||
className: "grid auth-with-password-form",
|
||||
onsubmit: (e) => {
|
||||
e.preventDefault();
|
||||
authWithPassword(data);
|
||||
},
|
||||
},
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label(
|
||||
{ htmlFor: "login_identity" },
|
||||
() => app.utils.sentenize(data.authMethods.password.identityFields.join(" or "), false),
|
||||
),
|
||||
t.input({
|
||||
id: "login_identity",
|
||||
name: "identity",
|
||||
type: () => {
|
||||
if (
|
||||
data.authMethods.password.identityFields.length == 1
|
||||
&& data.authMethods.password.identityFields[0] == "email"
|
||||
) {
|
||||
return "email";
|
||||
}
|
||||
return "text";
|
||||
},
|
||||
required: true,
|
||||
autofocus: true,
|
||||
value: () => data.identity,
|
||||
oninput: (e) => (data.identity = e.target.value),
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.div(
|
||||
{ className: "fields" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: "login_pass" }, "Password"),
|
||||
t.input({
|
||||
id: "login_pass",
|
||||
name: "password",
|
||||
required: true,
|
||||
type: () => (data.showPassword ? "text" : "password"),
|
||||
value: () => data.password,
|
||||
oninput: (e) => (data.password = e.target.value),
|
||||
}),
|
||||
),
|
||||
t.div(
|
||||
{ className: "field addon" },
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
tabIndex: -1,
|
||||
className: "btn sm transparent secondary circle tooltip-right",
|
||||
ariaDescription: app.attrs.tooltip(() =>
|
||||
data.showPassword ? "Hide password" : "Show password"
|
||||
),
|
||||
onclick: () => (data.showPassword = !data.showPassword),
|
||||
},
|
||||
t.i({
|
||||
className: () => (data.showPassword ? "ri-eye-off-line" : "ri-eye-line"),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
t.a(
|
||||
{
|
||||
href: "#/request-password-reset",
|
||||
className: "link-hint m-t-5",
|
||||
onclick: (e) => e.stopPropagation(),
|
||||
},
|
||||
t.small(null, "Forgotten password"),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.button(
|
||||
{
|
||||
className: () => `btn lg block next ${data.isPasswordAuthSubmitting ? "loading" : ""}`,
|
||||
disabled: () => data.isPasswordAuthSubmitting,
|
||||
},
|
||||
t.span({ className: "txt" }, () => (data.totalSteps > 1 ? "Next" : "Login")),
|
||||
t.i({ className: "ri-arrow-right-line" }),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Request OTP
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
async function requestOTP(data) {
|
||||
if (data.isOTPRequestSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
data.isOTPRequestSubmitting = true;
|
||||
|
||||
try {
|
||||
const result = await app.pb.collection("_superusers").requestOTP(data.otpEmail);
|
||||
data.otpId = result.otpId;
|
||||
data.lastOTPId = data.otpId;
|
||||
app.toasts.removeAll();
|
||||
app.store.errors = null;
|
||||
} catch (err) {
|
||||
// reset the form
|
||||
if (err.status == 429) {
|
||||
data.otpId = data.lastOTPId;
|
||||
}
|
||||
|
||||
app.checkApiError(err);
|
||||
}
|
||||
|
||||
data.isOTPRequestSubmitting = false;
|
||||
}
|
||||
|
||||
function requestOTPForm(data) {
|
||||
return t.form(
|
||||
{
|
||||
pbEvent: "requestOTPForm",
|
||||
className: "grid request-otp-form",
|
||||
onsubmit: (e) => {
|
||||
e.preventDefault();
|
||||
requestOTP(data);
|
||||
},
|
||||
},
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: "otp_email" }, "Email"),
|
||||
t.input({
|
||||
id: "otp_email",
|
||||
name: "email",
|
||||
type: "email",
|
||||
required: true,
|
||||
autofocus: true,
|
||||
value: () => data.otpEmail,
|
||||
oninput: (e) => (data.otpEmail = e.target.value),
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.button(
|
||||
{
|
||||
className: () => `btn lg block ${data.isOTPRequestSubmitting ? "loading" : ""}`,
|
||||
disabled: () => data.isOTPRequestSubmitting,
|
||||
},
|
||||
t.i({ className: "ri-mail-send-line" }),
|
||||
t.span({ className: "txt" }, "Send OTP"),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Auth with OTP
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
async function authWithOTP(data) {
|
||||
if (data.isOTPAuthSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
data.isOTPAuthSubmitting = true;
|
||||
|
||||
try {
|
||||
await app.pb.collection("_superusers").authWithOTP(data.otpId || data.lastOTPId, data.otpPassword, {
|
||||
mfaId: data.mfaId,
|
||||
});
|
||||
app.toasts.removeAll();
|
||||
app.store.errors = null;
|
||||
app.utils.toRememberedPath();
|
||||
} catch (err) {
|
||||
app.checkApiError(err);
|
||||
}
|
||||
|
||||
data.isOTPAuthSubmitting = false;
|
||||
}
|
||||
|
||||
function authWithOTPForm(data) {
|
||||
return t.form(
|
||||
{
|
||||
pbEvent: "authWithOTPForm",
|
||||
className: "grid auht-with-otp-form",
|
||||
onsubmit: (e) => {
|
||||
e.preventDefault();
|
||||
authWithOTP(data);
|
||||
},
|
||||
},
|
||||
() => {
|
||||
if (data.otpEmail) {
|
||||
return t.div(
|
||||
{ className: "col-12" },
|
||||
t.div(
|
||||
{ className: "content txt-center" },
|
||||
"Check your ",
|
||||
t.strong(null, data.otpEmail),
|
||||
" inbox and enter below the received One-time password (OTP).",
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: "otp_id" }, "Id"),
|
||||
t.input({
|
||||
id: "otp_id",
|
||||
name: "otpId",
|
||||
type: "text",
|
||||
required: true,
|
||||
placeholder: () => data.lastOTPId,
|
||||
value: () => data.otpId,
|
||||
onchange: (e) => {
|
||||
data.otpId = e.target.value || data.lastOTPId;
|
||||
e.target.value = data.otpId;
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.div(
|
||||
{ className: "fields" },
|
||||
t.div(
|
||||
{ className: "field" },
|
||||
t.label({ htmlFor: "otp_password" }, "One-time password"),
|
||||
t.input({
|
||||
id: "otp_password",
|
||||
name: "password",
|
||||
required: true,
|
||||
type: () => (data.showPassword ? "text" : "password"),
|
||||
value: () => data.otpPassword,
|
||||
oninput: (e) => (data.otpPassword = e.target.value),
|
||||
}),
|
||||
),
|
||||
t.div(
|
||||
{ className: "field addon" },
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
tabIndex: -1,
|
||||
className: "btn sm transparent secondary circle tooltip-right",
|
||||
ariaDescription: app.attrs.tooltip(() =>
|
||||
data.showPassword ? "Hide password" : "Show password"
|
||||
),
|
||||
onclick: () => (data.showPassword = !data.showPassword),
|
||||
},
|
||||
t.i({
|
||||
className: () => (data.showPassword ? "ri-eye-off-line" : "ri-eye-line"),
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
t.div(
|
||||
{ className: "col-12" },
|
||||
t.button(
|
||||
{
|
||||
className: () => `btn lg block next ${data.isOTPAuthSubmitting ? "loading" : ""}`,
|
||||
disabled: () => data.isOTPAuthSubmitting,
|
||||
},
|
||||
t.span({ className: "txt" }, "Login"),
|
||||
t.i({ className: "ri-arrow-right-line" }),
|
||||
),
|
||||
t.div(
|
||||
{ className: "block m-t-sm txt-center" },
|
||||
t.button(
|
||||
{
|
||||
type: "button",
|
||||
className: "link-hint txt-sm",
|
||||
disabled: () => data.isOTPAuthSubmitting,
|
||||
onclick: () => {
|
||||
data.otpId = "";
|
||||
data.otpPassword = "";
|
||||
},
|
||||
},
|
||||
"Request another OTP",
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user