1
0
mirror of https://github.com/hrfee/jfa-go.git synced 2024-12-22 17:10:10 +00:00

userpage: add password change card, validation, rearrange page

functionality not done yet, just comitting here because there were lots
of adjustments to layout stuff, accomodating for most combinations of
card presence/size.
This commit is contained in:
Harvey Tindall 2023-06-22 18:31:16 +01:00
parent 4496e1d509
commit 12ce669566
Signed by: hrfee
GPG Key ID: BBC65952848FB1A2
7 changed files with 191 additions and 82 deletions

View File

@ -23,6 +23,7 @@
window.matrixEnabled = {{ .matrixEnabled }}; window.matrixEnabled = {{ .matrixEnabled }};
window.matrixRequired = {{ .matrixRequired }}; window.matrixRequired = {{ .matrixRequired }};
window.matrixUserID = "{{ .matrixUser }}"; window.matrixUserID = "{{ .matrixUser }}";
window.validationStrings = JSON.parse({{ .validationStrings }});
</script> </script>
{{ template "header.html" . }} {{ template "header.html" . }}
<title>{{ .lang.Strings.myAccount }}</title> <title>{{ .lang.Strings.myAccount }}</title>
@ -113,10 +114,41 @@
<span class="heading mb-2">{{ .strings.contactMethods }}</span> <span class="heading mb-2">{{ .strings.contactMethods }}</span>
<div class="content flex justify-between flex-col h-100"></div> <div class="content flex justify-between flex-col h-100"></div>
</div> </div>
<div class="card @low dark:~d_neutral unfocused" id="card-status"> <div>
<span class="heading mb-2">{{ .strings.expiry }}</span> <div class="card @low dark:~d_neutral content" id="card-password">
<aside class="aside ~warning user-expiry my-4"></aside> <span class="heading row mb-2">{{ .strings.changePassword }}</span>
<div class="user-expiry-countdown"></div> <div class="">
<div class="my-2">
<span class="label supra row">{{ .strings.passwordRequirementsHeader }}</span>
<ul>
{{ range $key, $value := .requirements }}
<li class="" id="requirement-{{ $key }}" min="{{ $value }}">
<span class="badge lg ~positive requirement-valid"></span> <span class="content requirement-content"></span>
</li>
{{ end }}
</ul>
</div>
<div class="my-2">
<label class="label supra" for="user-old-password">{{ .strings.oldPassword }}</label>
<input type="password" class="input ~neutral @low mt-2 mb-4" placeholder="{{ .strings.password }}" id="user-old-password" aria-label="{{ .strings.oldPassword }}">
<label class="label supra" for="user-new-password">{{ .strings.newPassword }}</label>
<input type="password" class="input ~neutral @low mt-2 mb-4" placeholder="{{ .strings.password }}" id="user-new-password" aria-label="{{ .strings.newPassword }}">
<label class="label supra" for="user-reenter-password">{{ .strings.reEnterPassword }}</label>
<input type="password" class="input ~neutral @low mt-2 mb-4" placeholder="{{ .strings.password }}" id="user-reenter-new-password" aria-label="{{ .strings.reEnterPassword }}">
<span class="button ~info @low full-width center mt-4" id="user-password-submit">
{{ .strings.changePassword }}
</span>
</div>
</div>
</div>
</div>
<div>
<div class="card @low dark:~d_neutral unfocused" id="card-status">
<span class="heading mb-2">{{ .strings.expiry }}</span>
<aside class="aside ~warning user-expiry my-4"></aside>
<div class="user-expiry-countdown"></div>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -8,6 +8,8 @@
"accountDetails": "Details", "accountDetails": "Details",
"emailAddress": "Email", "emailAddress": "Email",
"username": "Username", "username": "Username",
"oldPassword": "Old Password",
"newPassword": "New Password",
"password": "Password", "password": "Password",
"reEnterPassword": "Re-enter Password", "reEnterPassword": "Re-enter Password",
"reEnterPasswordInvalid": "Passwords are not the same.", "reEnterPasswordInvalid": "Passwords are not the same.",
@ -31,7 +33,8 @@
"resetPasswordThroughJellyfin": "To reset your password, visit {jfLink} and press the \"Forgot Password\" button.", "resetPasswordThroughJellyfin": "To reset your password, visit {jfLink} and press the \"Forgot Password\" button.",
"resetPasswordThroughLink": "To reset your password, enter your username, email address or a linked contact method username, and submit. A link will be sent to reset your password.", "resetPasswordThroughLink": "To reset your password, enter your username, email address or a linked contact method username, and submit. A link will be sent to reset your password.",
"resetSent": "Reset Sent.", "resetSent": "Reset Sent.",
"resetSentDescription": "If an account with the given username/contact method exists, a password reset link has been sent via all contact methods available. The code will expire in 30 minutes." "resetSentDescription": "If an account with the given username/contact method exists, a password reset link has been sent via all contact methods available. The code will expire in 30 minutes.",
"changePassword": "Change Password"
}, },
"notifications": { "notifications": {
"errorUserExists": "User already exists.", "errorUserExists": "User already exists.",

View File

@ -2,7 +2,7 @@ import { Modal } from "./modules/modal.js";
import { notificationBox, whichAnimationEvent } from "./modules/common.js"; import { notificationBox, whichAnimationEvent } from "./modules/common.js";
import { _get, _post, toggleLoader, addLoader, removeLoader, toDateString } from "./modules/common.js"; import { _get, _post, toggleLoader, addLoader, removeLoader, toDateString } from "./modules/common.js";
import { loadLangSelector } from "./modules/lang.js"; import { loadLangSelector } from "./modules/lang.js";
import { initValidator } from "./modules/validator.js"; import { Validator, ValidatorConf } from "./modules/validator.js";
import { Discord, Telegram, Matrix, ServiceConfiguration, MatrixConfiguration } from "./modules/account-linking.js"; import { Discord, Telegram, Matrix, ServiceConfiguration, MatrixConfiguration } from "./modules/account-linking.js";
interface formWindow extends Window { interface formWindow extends Window {
@ -69,7 +69,7 @@ if (window.telegramEnabled) {
const radio = document.getElementById("contact-via-telegram") as HTMLInputElement; const radio = document.getElementById("contact-via-telegram") as HTMLInputElement;
radio.parentElement.classList.remove("unfocused"); radio.parentElement.classList.remove("unfocused");
radio.checked = true; radio.checked = true;
validatorFunc(); validator.validate();
} }
}; };
@ -101,7 +101,7 @@ if (window.discordEnabled) {
const radio = document.getElementById("contact-via-discord") as HTMLInputElement; const radio = document.getElementById("contact-via-discord") as HTMLInputElement;
radio.parentElement.classList.remove("unfocused") radio.parentElement.classList.remove("unfocused")
radio.checked = true; radio.checked = true;
validatorFunc(); validator.validate();
} }
}; };
@ -133,7 +133,7 @@ if (window.matrixEnabled) {
const radio = document.getElementById("contact-via-matrix") as HTMLInputElement; const radio = document.getElementById("contact-via-matrix") as HTMLInputElement;
radio.parentElement.classList.remove("unfocused"); radio.parentElement.classList.remove("unfocused");
radio.checked = true; radio.checked = true;
validatorFunc(); validator.validate();
} }
}; };
@ -162,7 +162,7 @@ if (window.userExpiryEnabled) {
} }
const form = document.getElementById("form-create") as HTMLFormElement; const form = document.getElementById("form-create") as HTMLFormElement;
const submitButton = form.querySelector("input[type=submit]") as HTMLInputElement; const submitInput = form.querySelector("input[type=submit]") as HTMLInputElement;
const submitSpan = form.querySelector("span.submit") as HTMLSpanElement; const submitSpan = form.querySelector("span.submit") as HTMLSpanElement;
const submitText = submitSpan.textContent; const submitText = submitSpan.textContent;
let usernameField = document.getElementById("create-username") as HTMLInputElement; let usernameField = document.getElementById("create-username") as HTMLInputElement;
@ -242,12 +242,19 @@ interface GreCAPTCHA {
declare var grecaptcha: GreCAPTCHA declare var grecaptcha: GreCAPTCHA
let r = initValidator(passwordField, rePasswordField, submitButton, submitSpan, baseValidator); let validatorConf: ValidatorConf = {
var requirements = r[0]; passwordField: passwordField,
var validatorFunc = r[1] as () => void; rePasswordField: rePasswordField,
submitInput: submitInput,
submitButton: submitSpan,
validatorFunc: baseValidator
};
let validator = new Validator(validatorConf);
var requirements = validator.requirements;
if (window.emailRequired) { if (window.emailRequired) {
emailField.addEventListener("keyup", validatorFunc) emailField.addEventListener("keyup", validator.validate)
} }
interface respDTO { interface respDTO {
@ -287,7 +294,7 @@ const genCaptcha = () => {
if (window.captcha && !window.reCAPTCHA) { if (window.captcha && !window.reCAPTCHA) {
genCaptcha(); genCaptcha();
(document.getElementById("captcha-regen") as HTMLSpanElement).onclick = genCaptcha; (document.getElementById("captcha-regen") as HTMLSpanElement).onclick = genCaptcha;
captchaInput.onkeyup = validatorFunc; captchaInput.onkeyup = validator.validate;
} }
const create = (event: SubmitEvent) => { const create = (event: SubmitEvent) => {
@ -386,6 +393,6 @@ const create = (event: SubmitEvent) => {
}); });
}; };
validatorFunc(); validator.validate();
form.onsubmit = create; form.onsubmit = create;

View File

@ -60,8 +60,21 @@ class Requirement {
validate = (count: number) => { this.valid = (count >= this._minCount); } validate = (count: number) => { this.valid = (count >= this._minCount); }
} }
export function initValidator(passwordField: HTMLInputElement, rePasswordField: HTMLInputElement, submitButton: HTMLInputElement, submitSpan: HTMLSpanElement, validatorFunc?: (oncomplete: (valid: boolean) => void) => void): ({ [category: string]: Requirement }|(() => void))[] { export interface ValidatorConf {
var defaultPwValStrings: pwValStrings = { passwordField: HTMLInputElement;
rePasswordField: HTMLInputElement;
submitInput?: HTMLInputElement;
submitButton: HTMLSpanElement;
validatorFunc?: (oncomplete: (valid: boolean) => void) => void;
}
export interface Validation { [name: string]: number }
export interface Requirements { [category: string]: Requirement };
export class Validator {
private _conf: ValidatorConf;
private _requirements: Requirements = {};
private _defaultPwValStrings: pwValStrings = {
length: { length: {
singular: "Must have at least {n} character", singular: "Must have at least {n} character",
plural: "Must have at least {n} characters" plural: "Must have at least {n} characters"
@ -82,39 +95,34 @@ export function initValidator(passwordField: HTMLInputElement, rePasswordField:
singular: "Must have at least {n} special character", singular: "Must have at least {n} special character",
plural: "Must have at least {n} special characters" plural: "Must have at least {n} special characters"
} }
};
private _checkPasswords = () => {
return this._conf.passwordField.value == this._conf.rePasswordField.value;
} }
const checkPasswords = () => { validate = () => {
return passwordField.value == rePasswordField.value; const pw = this._checkPasswords();
} this._conf.validatorFunc((valid: boolean) => {
const checkValidity = () => {
const pw = checkPasswords();
validatorFunc((valid: boolean) => {
if (pw && valid) { if (pw && valid) {
rePasswordField.setCustomValidity(""); this._conf.rePasswordField.setCustomValidity("");
submitButton.disabled = false; if (this._conf.submitInput) this._conf.submitInput.disabled = false;
submitSpan.removeAttribute("disabled"); this._conf.submitButton.removeAttribute("disabled");
} else if (!pw) { } else if (!pw) {
rePasswordField.setCustomValidity(window.invalidPassword); this._conf.rePasswordField.setCustomValidity(window.invalidPassword);
submitButton.disabled = true; if (this._conf.submitInput) this._conf.submitInput.disabled = true;
submitSpan.setAttribute("disabled", ""); this._conf.submitButton.setAttribute("disabled", "");
} else { } else {
rePasswordField.setCustomValidity(""); this._conf.rePasswordField.setCustomValidity("");
submitButton.disabled = true; if (this._conf.submitInput) this._conf.submitInput.disabled = true;
submitSpan.setAttribute("disabled", ""); this._conf.submitButton.setAttribute("disabled", "");
} }
}); });
}; };
rePasswordField.addEventListener("keyup", checkValidity); private _isInt = (s: string): boolean => { return (s >= '0' && s <= '9'); }
passwordField.addEventListener("keyup", checkValidity);
private _testStrings = (f: pwValString): boolean => {
// Incredible code right here
const isInt = (s: string): boolean => { return (s in ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"]); }
const testStrings = (f: pwValString): boolean => {
const testString = (s: string): boolean => { const testString = (s: string): boolean => {
if (s == "" || !s.includes("{n}")) { return false; } if (s == "" || !s.includes("{n}")) { return false; }
return true; return true;
@ -122,14 +130,12 @@ export function initValidator(passwordField: HTMLInputElement, rePasswordField:
return testString(f.singular) && testString(f.plural); return testString(f.singular) && testString(f.plural);
} }
interface Validation { [name: string]: number } private _validate = (s: string): Validation => {
const validate = (s: string): Validation => {
let v: Validation = {}; let v: Validation = {};
for (let criteria of ["length", "lowercase", "uppercase", "number", "special"]) { v[criteria] = 0; } for (let criteria of ["length", "lowercase", "uppercase", "number", "special"]) { v[criteria] = 0; }
v["length"] = s.length; v["length"] = s.length;
for (let c of s) { for (let c of s) {
if (isInt(c)) { v["number"]++; } if (this._isInt(c)) { v["number"]++; }
else { else {
const upper = c.toUpperCase(); const upper = c.toUpperCase();
if (upper == c.toLowerCase()) { v["special"]++; } if (upper == c.toLowerCase()) { v["special"]++; }
@ -141,27 +147,37 @@ export function initValidator(passwordField: HTMLInputElement, rePasswordField:
} }
return v return v
} }
passwordField.addEventListener("keyup", () => {
const v = validate(passwordField.value); private _bindRequirements = () => {
for (let criteria in requirements) {
requirements[criteria].validate(v[criteria]);
}
});
var requirements: { [category: string]: Requirement } = {};
if (!window.validationStrings) {
window.validationStrings = defaultPwValStrings;
} else {
for (let category in window.validationStrings) { for (let category in window.validationStrings) {
if (!testStrings(window.validationStrings[category])) { if (!this._testStrings(window.validationStrings[category])) {
window.validationStrings[category] = defaultPwValStrings[category]; window.validationStrings[category] = this._defaultPwValStrings[category];
} }
const el = document.getElementById("requirement-" + category); const el = document.getElementById("requirement-" + category);
if (el) { if (typeof(el) === 'undefined' || el == null) continue;
requirements[category] = new Requirement(category, el as HTMLLIElement); this._requirements[category] = new Requirement(category, el as HTMLLIElement);
}
};
get requirements(): Requirements { return this._requirements };
constructor(conf: ValidatorConf) {
this._conf = conf;
if (!(this._conf.validatorFunc)) {
this._conf.validatorFunc = (oncomplete: (valid: boolean) => void) => { oncomplete(true); };
}
this._conf.rePasswordField.addEventListener("keyup", this.validate);
this._conf.passwordField.addEventListener("keyup", this.validate);
this._conf.passwordField.addEventListener("keyup", () => {
const v = this._validate(this._conf.passwordField.value);
for (let criteria in this._requirements) {
this._requirements[criteria].validate(v[criteria]);
} }
});
if (!window.validationStrings) {
window.validationStrings = this._defaultPwValStrings;
} else {
this._bindRequirements();
} }
} }
return [requirements, checkValidity]
} }

View File

@ -1,5 +1,5 @@
import { Modal } from "./modules/modal.js"; import { Modal } from "./modules/modal.js";
import { initValidator } from "./modules/validator.js"; import { Validator, ValidatorConf } from "./modules/validator.js";
import { _post, addLoader, removeLoader } from "./modules/common.js"; import { _post, addLoader, removeLoader } from "./modules/common.js";
import { loadLangSelector } from "./modules/lang.js"; import { loadLangSelector } from "./modules/lang.js";
@ -35,14 +35,22 @@ loadLangSelector("pwr");
declare var window: formWindow; declare var window: formWindow;
const form = document.getElementById("form-create") as HTMLFormElement; const form = document.getElementById("form-create") as HTMLFormElement;
const submitButton = form.querySelector("input[type=submit]") as HTMLInputElement; const submitInput = form.querySelector("input[type=submit]") as HTMLInputElement;
const submitSpan = form.querySelector("span.submit") as HTMLSpanElement; const submitSpan = form.querySelector("span.submit") as HTMLSpanElement;
const passwordField = document.getElementById("create-password") as HTMLInputElement; const passwordField = document.getElementById("create-password") as HTMLInputElement;
const rePasswordField = document.getElementById("create-reenter-password") as HTMLInputElement; const rePasswordField = document.getElementById("create-reenter-password") as HTMLInputElement;
window.successModal = new Modal(document.getElementById("modal-success"), true); window.successModal = new Modal(document.getElementById("modal-success"), true);
var requirements = initValidator(passwordField, rePasswordField, submitButton, submitSpan) let validatorConf: ValidatorConf = {
passwordField: passwordField,
rePasswordField: rePasswordField,
submitInput: submitInput,
submitButton: submitSpan
};
var validator = new Validator(validatorConf);
var requirements = validator.requirements;
interface sendDTO { interface sendDTO {
pin: string; pin: string;
@ -81,3 +89,5 @@ form.onsubmit = (event: Event) => {
} }
}, true); }, true);
}; };
validator.validate();

View File

@ -4,6 +4,7 @@ import { Modal } from "./modules/modal.js";
import { _get, _post, _delete, notificationBox, whichAnimationEvent, toDateString, toggleLoader } from "./modules/common.js"; import { _get, _post, _delete, notificationBox, whichAnimationEvent, toDateString, toggleLoader } from "./modules/common.js";
import { Login } from "./modules/login.js"; import { Login } from "./modules/login.js";
import { Discord, Telegram, Matrix, ServiceConfiguration, MatrixConfiguration } from "./modules/account-linking.js"; import { Discord, Telegram, Matrix, ServiceConfiguration, MatrixConfiguration } from "./modules/account-linking.js";
import { Validator, ValidatorConf } from "./modules/validator.js";
interface userWindow extends Window { interface userWindow extends Window {
jellyfinID: string; jellyfinID: string;
@ -88,6 +89,7 @@ const grid = document.querySelector(".grid");
var rootCard = document.getElementById("card-user"); var rootCard = document.getElementById("card-user");
var contactCard = document.getElementById("card-contact"); var contactCard = document.getElementById("card-contact");
var statusCard = document.getElementById("card-status"); var statusCard = document.getElementById("card-status");
var passwordCard = document.getElementById("card-password");
interface MyDetailsContactMethod { interface MyDetailsContactMethod {
value: string; value: string;
@ -385,6 +387,29 @@ const matrixConf: MatrixConfiguration = {
let matrix = new Matrix(matrixConf); let matrix = new Matrix(matrixConf);
const oldPasswordField = document.getElementById("user-old-password") as HTMLInputElement;
const newPasswordField = document.getElementById("user-new-password") as HTMLInputElement;
const rePasswordField = document.getElementById("user-reenter-new-password") as HTMLInputElement;
const changePasswordButton = document.getElementById("user-password-submit") as HTMLSpanElement;
let baseValidator = (oncomplete: (valid: boolean) => void): void => {
if (oldPasswordField.value.length == 0) return oncomplete(false);
oncomplete(true);
};
let validatorConf: ValidatorConf = {
passwordField: newPasswordField,
rePasswordField: rePasswordField,
submitButton: changePasswordButton,
validatorFunc: baseValidator
};
let validator = new Validator(validatorConf);
let requirements = validator.requirements;
oldPasswordField.addEventListener("keyup", validator.validate);
// FIXME: Submit & Validate
document.addEventListener("details-reload", () => { document.addEventListener("details-reload", () => {
_get("/my/details", null, (req: XMLHttpRequest) => { _get("/my/details", null, (req: XMLHttpRequest) => {
if (req.readyState == 4) { if (req.readyState == 4) {
@ -448,20 +473,10 @@ document.addEventListener("details-reload", () => {
} }
if (typeof(messageCard) != "undefined" && messageCard != null) { if (typeof(messageCard) != "undefined" && messageCard != null) {
let largestNonMessageCardHeight = 0; setBestRowSpan(messageCard, false);
const cards = grid.querySelectorAll(".card") as NodeListOf<HTMLElement>; // contactCard.querySelector(".content").classList.add("h-100");
for (let i = 0; i < cards.length; i++) { } else if (!statusCard.classList.contains("unfocused")) {
if (cards[i].id == "card-message") continue; setBestRowSpan(passwordCard, true);
if (computeRealHeight(cards[i]) > largestNonMessageCardHeight) {
largestNonMessageCardHeight = computeRealHeight(cards[i]);
}
}
let rowSpan = Math.ceil(computeRealHeight(messageCard) / largestNonMessageCardHeight);
if (rowSpan > 0)
messageCard.style.gridRow = `span ${rowSpan}`;
} }
} }
}); });
@ -474,11 +489,36 @@ login.onLogin = () => {
document.dispatchEvent(new CustomEvent("details-reload")); document.dispatchEvent(new CustomEvent("details-reload"));
}; };
const setBestRowSpan = (el: HTMLElement, setOnParent: boolean) => {
let largestNonMessageCardHeight = 0;
const cards = grid.querySelectorAll(".card") as NodeListOf<HTMLElement>;
for (let i = 0; i < cards.length; i++) {
if (cards[i].id == el.id) continue;
if (computeRealHeight(cards[i]) > largestNonMessageCardHeight) {
largestNonMessageCardHeight = computeRealHeight(cards[i]);
}
}
let rowSpan = Math.ceil(computeRealHeight(el) / largestNonMessageCardHeight);
if (rowSpan > 0)
(setOnParent ? el.parentElement : el).style.gridRow = `span ${rowSpan}`;
};
const computeRealHeight = (el: HTMLElement): number => { const computeRealHeight = (el: HTMLElement): number => {
let children = el.children as HTMLCollectionOf<HTMLElement>; let children = el.children as HTMLCollectionOf<HTMLElement>;
let total = 0; let total = 0;
for (let i = 0; i < children.length; i++) { for (let i = 0; i < children.length; i++) {
total += children[i].offsetHeight; // Cope with the contact method card expanding to fill, by counting each contact method individually
if (el.id == "card-contact" && children[i].classList.contains("content")) {
// console.log("FOUND CARD_CONTACT, OG:", total + children[i].offsetHeight);
for (let j = 0; j < children[i].children.length; j++) {
total += (children[i].children[j] as HTMLElement).offsetHeight;
}
// console.log("NEW:", total);
} else {
total += children[i].offsetHeight;
}
} }
return total; return total;
} }

View File

@ -183,10 +183,11 @@ func (app *appContext) MyUserPage(gc *gin.Context) {
"notifications": notificationsEnabled, "notifications": notificationsEnabled,
"username": !app.config.Section("email").Key("no_username").MustBool(false), "username": !app.config.Section("email").Key("no_username").MustBool(false),
"strings": app.storage.lang.User[lang].Strings, "strings": app.storage.lang.User[lang].Strings,
"validationStrings": app.storage.lang.User[lang].ValidationStrings, "validationStrings": app.storage.lang.User[lang].validationStringsJSON,
"language": app.storage.lang.User[lang].JSON, "language": app.storage.lang.User[lang].JSON,
"langName": lang, "langName": lang,
"jfLink": app.config.Section("ui").Key("redirect_url").String(), "jfLink": app.config.Section("ui").Key("redirect_url").String(),
"requirements": app.validator.getCriteria(),
} }
if telegramEnabled { if telegramEnabled {
data["telegramUsername"] = app.telegram.username data["telegramUsername"] = app.telegram.username