diff --git a/otoroshi/app/otoroshi/views/backoffice/unauthmain.scala.html b/otoroshi/app/otoroshi/views/backoffice/unauthmain.scala.html index 0ee1aaf6c7..f4d70d7e24 100644 --- a/otoroshi/app/otoroshi/views/backoffice/unauthmain.scala.html +++ b/otoroshi/app/otoroshi/views/backoffice/unauthmain.scala.html @@ -6,12 +6,34 @@ @title + @moreStyles +
diff --git a/otoroshi/javascript/src/pages/U2FLoginPage.js b/otoroshi/javascript/src/pages/U2FLoginPage.js index 5311cb2a3f..a99f0e23bc 100644 --- a/otoroshi/javascript/src/pages/U2FLoginPage.js +++ b/otoroshi/javascript/src/pages/U2FLoginPage.js @@ -1,7 +1,8 @@ -import React, { Component } from 'react'; +import React, { Component } from "react"; function Base64Url() { - let chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; + let chars = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; // Use a lookup table to find the index. let lookup = new Uint8Array(256); @@ -13,7 +14,7 @@ function Base64Url() { let bytes = new Uint8Array(arraybuffer), i, len = bytes.length, - base64url = ''; + base64url = ""; for (i = 0; i < len; i += 3) { base64url += chars[bytes[i] >> 2]; @@ -76,7 +77,7 @@ function responseToObject(response) { try { clientExtensionResults = response.getClientExtensionResults(); } catch (e) { - console.error('getClientExtensionResults failed', e); + console.error("getClientExtensionResults failed", e); } if (response.response.attestationObject) { @@ -84,8 +85,12 @@ function responseToObject(response) { type: response.type, id: response.id, response: { - attestationObject: base64url.fromByteArray(response.response.attestationObject), - clientDataJSON: base64url.fromByteArray(response.response.clientDataJSON), + attestationObject: base64url.fromByteArray( + response.response.attestationObject + ), + clientDataJSON: base64url.fromByteArray( + response.response.clientDataJSON + ), }, clientExtensionResults, }; @@ -94,11 +99,16 @@ function responseToObject(response) { type: response.type, id: response.id, response: { - authenticatorData: base64url.fromByteArray(response.response.authenticatorData), - clientDataJSON: base64url.fromByteArray(response.response.clientDataJSON), + authenticatorData: base64url.fromByteArray( + response.response.authenticatorData + ), + clientDataJSON: base64url.fromByteArray( + response.response.clientDataJSON + ), signature: base64url.fromByteArray(response.response.signature), userHandle: - response.response.userHandle && base64url.fromByteArray(response.response.userHandle), + response.response.userHandle && + base64url.fromByteArray(response.response.userHandle), }, clientExtensionResults, }; @@ -106,22 +116,271 @@ function responseToObject(response) { } } +// Inject styles for animations +const injectStyles = () => { + if (document.getElementById("otoroshi-admin-login-styles")) return; + const style = document.createElement("style"); + style.id = "otoroshi-admin-login-styles"; + style.textContent = ` + @keyframes oto-fade-in { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } + } + @keyframes oto-glow-pulse { + 0%, 100% { opacity: 0.4; } + 50% { opacity: 0.7; } + } + @keyframes oto-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } + .oto-login-input::placeholder { + color: rgba(255, 255, 255, 0.3); + } + .oto-login-input:focus { + border-color: #f9b000; + box-shadow: 0 0 0 1px #f9b000, 0 0 16px rgba(249, 176, 0, 0.12); + outline: none; + } + .oto-login-btn:hover:not(:disabled) { + background: #e6a200; + box-shadow: 0 8px 24px rgba(249, 176, 0, 0.3); + transform: translateY(-1px); + } + .oto-login-btn:active:not(:disabled) { + transform: translateY(0); + } + .oto-login-btn:disabled { + opacity: 0.7; + cursor: not-allowed; + } + .oto-webauthn-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.08); + border-color: rgba(255, 255, 255, 0.35); + } + .oto-footer-link:hover { + color: #f9b000; + } + `; + document.head.appendChild(style); +}; + +const styles = { + container: { + minHeight: "100vh", + display: "flex", + alignItems: "center", + justifyContent: "center", + background: "linear-gradient(135deg, #1b1b2f 0%, #162447 50%, #1f4068 100%)", + fontFamily: + '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, sans-serif', + padding: "24px", + position: "relative", + overflow: "hidden", + }, + backgroundOrb: { + position: "absolute", + top: "10%", + left: "50%", + transform: "translateX(-50%)", + width: "600px", + height: "600px", + background: + "radial-gradient(circle, rgba(249, 176, 0, 0.06) 0%, transparent 70%)", + pointerEvents: "none", + animation: "oto-glow-pulse 4s ease-in-out infinite", + }, + content: { + position: "relative", + zIndex: 1, + width: "100%", + maxWidth: "400px", + animation: "oto-fade-in 0.5s ease-out", + }, + brandSection: { + textAlign: "center", + marginBottom: "32px", + }, + logo: { + height: "120px", + width: "auto", + }, + card: { + background: "rgba(0, 0, 0, 0.35)", + backdropFilter: "blur(20px)", + border: "1px solid rgba(255, 255, 255, 0.1)", + borderRadius: "12px", + padding: "32px", + boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.4)", + }, + cardHeader: { + marginBottom: "28px", + textAlign: "center", + }, + title: { + margin: "0 0 8px", + color: "#fff", + fontSize: "24px", + fontWeight: "700", + letterSpacing: "-0.02em", + }, + subtitle: { + margin: 0, + color: "rgba(255, 255, 255, 0.6)", + fontSize: "14px", + lineHeight: "1.5", + }, + form: { + display: "flex", + flexDirection: "column", + gap: "20px", + }, + inputGroup: { + display: "flex", + flexDirection: "column", + gap: "8px", + }, + label: { + color: "rgba(255, 255, 255, 0.7)", + fontSize: "13px", + fontWeight: "500", + }, + input: { + width: "100%", + padding: "12px 14px", + fontSize: "14px", + color: "#fff", + backgroundColor: "rgba(0, 0, 0, 0.3)", + border: "1px solid rgba(255, 255, 255, 0.12)", + borderRadius: "8px", + outline: "none", + transition: "all 0.2s ease", + boxSizing: "border-box", + }, + errorContainer: { + display: "flex", + alignItems: "center", + gap: "8px", + padding: "12px 14px", + backgroundColor: "rgba(220, 38, 38, 0.1)", + border: "1px solid rgba(220, 38, 38, 0.2)", + borderRadius: "8px", + color: "#fca5a5", + fontSize: "13px", + }, + errorIcon: { + width: "16px", + height: "16px", + flexShrink: 0, + color: "#f87171", + }, + successContainer: { + display: "flex", + alignItems: "center", + gap: "8px", + padding: "12px 14px", + backgroundColor: "rgba(16, 185, 129, 0.1)", + border: "1px solid rgba(16, 185, 129, 0.2)", + borderRadius: "8px", + color: "#6ee7b7", + fontSize: "13px", + }, + successIcon: { + width: "16px", + height: "16px", + flexShrink: 0, + color: "#10b981", + }, + buttonGroup: { + display: "flex", + flexDirection: "column", + gap: "12px", + marginTop: "4px", + }, + button: { + width: "100%", + padding: "12px 20px", + fontSize: "14px", + fontWeight: "700", + color: "#1b1b1d", + background: "#f9b000", + border: "none", + borderRadius: "8px", + cursor: "pointer", + transition: "all 0.2s ease", + boxShadow: "0 4px 14px rgba(249, 176, 0, 0.2)", + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: "8px", + }, + webauthnButton: { + width: "100%", + padding: "12px 20px", + fontSize: "14px", + fontWeight: "500", + color: "rgba(255, 255, 255, 0.7)", + background: "transparent", + border: "1px solid rgba(255, 255, 255, 0.2)", + borderRadius: "8px", + cursor: "pointer", + transition: "all 0.2s ease", + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: "8px", + }, + spinner: { + width: "18px", + height: "18px", + animation: "oto-spin 1s linear infinite", + }, + spinnerTrack: { + opacity: 0.2, + }, + spinnerHead: { + opacity: 1, + }, + footer: { + display: "flex", + alignItems: "center", + justifyContent: "center", + gap: "8px", + marginTop: "32px", + color: "rgba(255, 255, 255, 0.35)", + fontSize: "12px", + }, + footerLink: { + color: "rgba(255, 255, 255, 0.5)", + textDecoration: "none", + transition: "color 0.2s ease", + }, +}; + export class U2FLoginPage extends Component { state = { - email: '', - password: '', + email: "", + password: "", error: null, message: null, + loading: false, }; + componentDidMount() { + injectStyles(); + } + onChange = (e) => { - this.setState({ [e.target.name]: e.target.value }); + this.setState({ [e.target.name]: e.target.value, error: null }); }; handleError = (mess, t) => { return (err) => { console.log(err && err.message ? err.message : err); - this.setState({ error: err && err.message ? err.message : err }); + this.setState({ + error: err && err.message ? err.message : err, + loading: false, + }); throw err; }; }; @@ -132,13 +391,13 @@ export class U2FLoginPage extends Component { } const username = this.state.email; const password = this.state.password; - this.setState({ message: null }); + this.setState({ message: null, loading: true, error: null }); return fetch(`/bo/simple/login`, { - method: 'POST', - credentials: 'include', + method: "POST", + credentials: "include", headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', + Accept: "application/json", + "Content-Type": "application/json", }, body: JSON.stringify({ username, @@ -146,11 +405,12 @@ export class U2FLoginPage extends Component { }), }).then((r) => { if (r && r.ok) { - window.location.href = '/bo/dashboard'; + this.setState({ message: "Login successful, redirecting..." }); + window.location.href = "/bo/dashboard"; } else { this.webAuthnLogin(); } - }, this.handleError('Login and/or password error, sorry ...')); + }, this.handleError("Invalid username or password")); }; webAuthnLogin = (e) => { @@ -160,13 +420,17 @@ export class U2FLoginPage extends Component { const username = this.state.email; const password = this.state.password; const label = this.state.label; - this.setState({ message: null }); + this.setState({ + message: "Waiting for security key...", + loading: true, + error: null, + }); fetch(`/bo/webauthn/login/start`, { - method: 'POST', - credentials: 'include', + method: "POST", + credentials: "include", headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', + Accept: "application/json", + "Content-Type": "application/json", }, body: JSON.stringify({ username, @@ -179,9 +443,9 @@ export class U2FLoginPage extends Component { if (r.status === 200 || r.status == 201) { return r.json(); } else { - throw new Error('Login and/or password error, sorry ...'); + throw new Error("Invalid username or password"); } - }, this.handleError('Login and/or password error, sorry ...')) + }, this.handleError("Invalid username or password")) .then((payload) => { const requestId = payload.requestId; const options = payload.request.publicKeyCredentialRequestOptions; @@ -190,22 +454,21 @@ export class U2FLoginPage extends Component { c.id = base64url.decode(c.id); return c; }); - console.log(options); return navigator.credentials .get( { publicKey: options, }, - this.handleError('Webauthn error, sorry ...') + this.handleError("WebAuthn authentication failed") ) .then((credentials) => { const json = responseToObject(credentials); return fetch(`/bo/webauthn/login/finish`, { - method: 'POST', - credentials: 'include', + method: "POST", + credentials: "include", headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', + Accept: "application/json", + "Content-Type": "application/json", }, body: JSON.stringify({ requestId, @@ -217,86 +480,187 @@ export class U2FLoginPage extends Component { }, }), }) - .then((r) => r.json(), this.handleError('Authentication error, sorry ...')) + .then((r) => r.json(), this.handleError("Authentication failed")) .then((data) => { this.setState( - { error: null, email: '', password: '', message: `Login successfully` }, + { + error: null, + email: "", + password: "", + message: "Login successful, redirecting...", + loading: false, + }, () => { - window.location.href = '/bo/dashboard'; + window.location.href = "/bo/dashboard"; } ); - }, this.handleError('Login error, sorry ...')); + }, this.handleError("Login failed")); }); - }, this.handleError('Login error, sorry ...')); + }, this.handleError("Authentication failed")); }; render() { + const { email, password, error, message, loading } = this.state; + const canSubmit = email && password && !loading; + return ( -
- -
-

Admin login

-

Log in to Otoroshi to continue

-
-
-
- -
- -
-
-
- -
- -
+
+ {/* Background glow */} +
+ +
+ {/* Logo */} +
+ Otoroshi
-
-
-

{!this.state.error && this.state.message}

-

- {!!this.state.error && this.state.error} -

+ + {/* Login card */} +
+
+

Admin console

+

Sign in to access Otoroshi

+ + +
+ + +
+ +
+ + +
+ + {error && ( +
+ + + + {error} +
+ )} + + {message && !error && ( +
+ + + + {message} +
+ )} + +
+ +
+ +
-
-
- - -
+ + {/* Footer */} +
+ Otoroshi API Gateway + ยท + + Documentation +
- +
); }