Added styling to the authorisation prompt etc.
This commit is contained in:
parent
86b32bb8eb
commit
f6346e3f25
26 changed files with 730 additions and 69 deletions
49
assets/hanyuu.css/main.css
Normal file
49
assets/hanyuu.css/main.css
Normal file
|
@ -0,0 +1,49 @@
|
|||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
[hidden],
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
:root {
|
||||
--font-regular: Verdana, Geneva, 'Dejavu Sans', Arial, Helvetica, sans-serif;
|
||||
--font-monospace: Consolas, 'Liberation Mono', Menlo, Courier, monospace;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #111;
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
line-height: 25px;
|
||||
font-family: var(--font-regular);
|
||||
overflow-y: scroll;
|
||||
position: static;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
pre, code {
|
||||
font-family: var(--font-monospace);
|
||||
}
|
||||
|
||||
a {
|
||||
color: #1e90ff;
|
||||
text-decoration: none;
|
||||
}
|
||||
a:visited {
|
||||
color: #6B4F80;
|
||||
}
|
||||
a:hover,
|
||||
a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
43
assets/oauth2.css/appinfo.css
Normal file
43
assets/oauth2.css/appinfo.css
Normal file
|
@ -0,0 +1,43 @@
|
|||
.oauth2-appinfo {}
|
||||
|
||||
.oauth2-appinfo-name {
|
||||
font-size: 2em;
|
||||
line-height: 1.5em;
|
||||
}
|
||||
|
||||
.oauth2-appinfo-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.oauth2-appinfo-link {
|
||||
display: flex;
|
||||
color: inherit;
|
||||
gap: 5px;
|
||||
align-items: center;
|
||||
background: #333;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.oauth2-appinfo-link-icon {
|
||||
flex: 0 0 auto;
|
||||
background-color: #fff;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
.oauth2-appinfo-link-icon-globe {
|
||||
mask: url('/images/globe-solid.svg') no-repeat center;
|
||||
}
|
||||
.oauth2-appinfo-link-text {
|
||||
font-size: .8em;
|
||||
line-height: 1.4em;
|
||||
}
|
||||
|
||||
.oauth2-appinfo-summary {
|
||||
font-size: .9em;
|
||||
line-height: 1.4em;
|
||||
}
|
||||
.oauth2-appinfo-summary p {
|
||||
margin: .5em 0;
|
||||
}
|
57
assets/oauth2.css/authorise.css
Normal file
57
assets/oauth2.css/authorise.css
Normal file
|
@ -0,0 +1,57 @@
|
|||
.oauth2-authorise-requesting {
|
||||
font-size: .8em;
|
||||
line-height: 1.4em;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
.oauth2-authorise-requesting p {
|
||||
margin: .5em 0;
|
||||
}
|
||||
|
||||
.oauth2-authorise-buttons {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.oauth2-authorise-button {
|
||||
background-color: #191919;
|
||||
font-family: var(--font-regular);
|
||||
font-size: 1.2em;
|
||||
line-height: 1.4em;
|
||||
padding: 5px 10px;
|
||||
min-width: 140px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: color .2s, background-color .2s, opacity .2s;
|
||||
border: 1px solid;
|
||||
border-radius: 2px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.oauth2-authorise-button[disabled] {
|
||||
opacity: .5;
|
||||
}
|
||||
|
||||
.oauth2-authorise-button-accept {
|
||||
color: #080;
|
||||
border-color: #0a0;
|
||||
}
|
||||
.oauth2-authorise-button-accept:hover,
|
||||
.oauth2-authorise-button-accept:focus {
|
||||
color: #191919;
|
||||
background-color: #0a0;
|
||||
}
|
||||
|
||||
.oauth2-authorise-button-deny {
|
||||
color: #c00;
|
||||
border-color: #a00;
|
||||
}
|
||||
.oauth2-authorise-button-deny:hover,
|
||||
.oauth2-authorise-button-deny:focus {
|
||||
color: #191919;
|
||||
background-color: #a00;
|
||||
}
|
21
assets/oauth2.css/banner.css
Normal file
21
assets/oauth2.css/banner.css
Normal file
|
@ -0,0 +1,21 @@
|
|||
.oauth2-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.oauth2-banner-text {
|
||||
font-size: .9em;
|
||||
line-height: 1.4em;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.oauth2-banner-logo {
|
||||
flex: 0 0 auto;
|
||||
background-color: #fff;
|
||||
mask: url('/images/flashii.svg') no-repeat center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
font-size: 0;
|
||||
}
|
||||
|
22
assets/oauth2.css/error.css
Normal file
22
assets/oauth2.css/error.css
Normal file
|
@ -0,0 +1,22 @@
|
|||
.oauth2-errorhead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.oauth2-errorhead-icon {
|
||||
flex: 0 0 auto;
|
||||
background-color: #fff;
|
||||
mask: url('/images/circle-exclamation-solid.svg') no-repeat center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.oauth2-errorhead-text {
|
||||
font-size: 1.8em;
|
||||
line-height: 1.4em;
|
||||
}
|
||||
|
||||
.oauth2-errorbody p {
|
||||
margin: .5em 1em;
|
||||
}
|
18
assets/oauth2.css/login.css
Normal file
18
assets/oauth2.css/login.css
Normal file
|
@ -0,0 +1,18 @@
|
|||
.oauth2-loginhead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.oauth2-loginhead-icon {
|
||||
flex: 0 0 auto;
|
||||
background-color: #fff;
|
||||
mask: url('/images/user-lock-solid.svg') no-repeat center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
.oauth2-loginhead-text {
|
||||
font-size: 1.8em;
|
||||
line-height: 1.4em;
|
||||
}
|
|
@ -1,29 +1,44 @@
|
|||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
.oauth2-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 0 auto;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
html, body {
|
||||
.oauth2-dialog {
|
||||
display: flex;
|
||||
flex: 1 0 auto;
|
||||
padding: 10px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #111;
|
||||
color: #fff;
|
||||
font: 12px/20px Verdana, Geneva, Arial, Helvetica, sans-serif;
|
||||
.oauth2-dialog-body {
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
background: #191919;
|
||||
box-shadow: 0 1px 2px #0009;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: light) {
|
||||
body {
|
||||
background-color: #ddd;
|
||||
color: #000;
|
||||
}
|
||||
.oauth2-header {
|
||||
background-image: url('/images/clouds.png');
|
||||
background-blend-mode: multiply;
|
||||
background-color: #8559a5;
|
||||
width: 100%;
|
||||
min-height: 4px;
|
||||
}
|
||||
|
||||
[hidden],
|
||||
.hidden {
|
||||
display: none !important;
|
||||
.oauth2-body {
|
||||
margin: 10px;
|
||||
}
|
||||
|
||||
@include banner.css;
|
||||
@include error.css;
|
||||
@include login.css;
|
||||
@include userhead.css;
|
||||
@include appinfo.css;
|
||||
@include scope.css;
|
||||
@include authorise.css;
|
||||
|
|
34
assets/oauth2.css/scope.css
Normal file
34
assets/oauth2.css/scope.css
Normal file
|
@ -0,0 +1,34 @@
|
|||
.oauth2-scope {
|
||||
background: #292929;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.oauth2-scope-header {
|
||||
border-bottom: 1px solid #494949;
|
||||
}
|
||||
|
||||
.oauth2-scope-perms {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.oauth2-scope-perm {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.oauth2-scope-perm-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
mask: url('/images/circle-check-regular.svg') no-repeat center;
|
||||
flex: 0 0 auto;
|
||||
background-color: #0a0;
|
||||
margin: 2px;
|
||||
}
|
||||
.oauth2-scope-perm-text {
|
||||
font-size: .8em;
|
||||
line-height: 1.4em;
|
||||
}
|
64
assets/oauth2.css/userhead.css
Normal file
64
assets/oauth2.css/userhead.css
Normal file
|
@ -0,0 +1,64 @@
|
|||
.oauth2-userhead {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: #0005;
|
||||
}
|
||||
|
||||
.oauth2-userhead-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.oauth2-userhead-main-avatar {
|
||||
flex: 0 0 auto;
|
||||
margin: 10px;
|
||||
}
|
||||
.oauth2-userhead-main-avatar-image {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.oauth2-userhead-main-avatar-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border: 0;
|
||||
}
|
||||
.oauth2-userhead-main-name {
|
||||
font-size: 1.8em;
|
||||
line-height: 1.4em;
|
||||
}
|
||||
.oauth2-userhead-main-name a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.oauth2-userhead-guise {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-image: repeating-linear-gradient(-45deg, #8559a57f, #8559a57f 10px, #1111117f 10px, #1111117f 20px);
|
||||
}
|
||||
.oauth2-userhead-guise-avatar {
|
||||
flex: 0 0 auto;
|
||||
margin: 10px;
|
||||
}
|
||||
.oauth2-userhead-guise-avatar-image {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.oauth2-userhead-guise-avatar-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border: 0;
|
||||
}
|
||||
.oauth2-userhead-guise-text {
|
||||
font-size: .8em;
|
||||
line-height: 1.5em;
|
||||
overflow: hidden;
|
||||
padding: 2px 0;
|
||||
}
|
||||
.oauth2-userhead-guise-text p {
|
||||
margin: 1px 0;
|
||||
}
|
|
@ -1 +1,32 @@
|
|||
/* beans */
|
||||
(() => {
|
||||
const authoriseButtons = document.querySelectorAll('.js-authorise-action');
|
||||
|
||||
for(const button of authoriseButtons) {
|
||||
button.disabled = false;
|
||||
|
||||
button.onclick = () => {
|
||||
for(const other of authoriseButtons)
|
||||
other.disabled = true;
|
||||
|
||||
const body = [];
|
||||
for(const name in button.dataset)
|
||||
body.push(encodeURIComponent(name) + '=' + encodeURIComponent(button.dataset[name]));
|
||||
|
||||
const xhr = new XMLHttpRequest;
|
||||
xhr.responseType = 'json';
|
||||
xhr.open('POST', '/oauth2/authorise');
|
||||
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
|
||||
xhr.onload = () => {
|
||||
if(xhr.response.redirect)
|
||||
location.assign(xhr.response.redirect);
|
||||
else
|
||||
location.assign('/oauth2/error?error=invalid_request');
|
||||
};
|
||||
xhr.onerror = () => {
|
||||
for(const other of authoriseButtons)
|
||||
other.disabled = false;
|
||||
};
|
||||
xhr.send(body.join('&'));
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
|
1
build.js
1
build.js
|
@ -21,6 +21,7 @@ const fs = require('fs');
|
|||
],
|
||||
css: [
|
||||
{ source: 'errors.css', target: '/', name: 'errors.css', },
|
||||
{ source: 'hanyuu.css', target: '/assets', name: 'hanyuu.{hash}.css', },
|
||||
{ source: 'oauth2.css', target: '/assets', name: 'oauth2.{hash}.css', },
|
||||
],
|
||||
twig: [
|
||||
|
|
|
@ -9,3 +9,5 @@ misuzu:secret beans
|
|||
|
||||
oauth2:device:verification_uri https://hau.local/oauth2/device
|
||||
oauth2:device:verification_uri_complete https://hau.local/oauth2/device?code=%s
|
||||
|
||||
csrfp:secret change this please
|
||||
|
|
1
public/images/circle-check-regular.svg
Normal file
1
public/images/circle-check-regular.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M256 48a208 208 0 1 1 0 416 208 208 0 1 1 0-416zm0 464A256 256 0 1 0 256 0a256 256 0 1 0 0 512zM369 209c9.4-9.4 9.4-24.6 0-33.9s-24.6-9.4-33.9 0l-111 111-47-47c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l64 64c9.4 9.4 24.6 9.4 33.9 0L369 209z"/></svg>
|
After Width: | Height: | Size: 471 B |
1
public/images/circle-exclamation-solid.svg
Normal file
1
public/images/circle-exclamation-solid.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M256 512A256 256 0 1 0 256 0a256 256 0 1 0 0 512zm0-384c13.3 0 24 10.7 24 24l0 112c0 13.3-10.7 24-24 24s-24-10.7-24-24l0-112c0-13.3 10.7-24 24-24zM224 352a32 32 0 1 1 64 0 32 32 0 1 1 -64 0z"/></svg>
|
After Width: | Height: | Size: 421 B |
BIN
public/images/clouds.png
Normal file
BIN
public/images/clouds.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 283 KiB |
1
public/images/flashii.svg
Normal file
1
public/images/flashii.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 1000 1000" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><g id="g285"><path id="rect18" d="M912.855,608.974l-86.145,321.498l-57.813,-15.491l86.145,-321.498l57.813,15.491Z"/><path id="rect16" d="M854.364,442.816l-86.145,321.498l-57.813,-15.49l86.145,-321.499l57.813,15.491Z"/><path id="rect14" d="M772.856,362.559l-86.145,321.498l-57.813,-15.491l86.145,-321.498l57.813,15.491Z"/><path id="rect12" d="M676.004,339.569l-86.145,321.498l-57.813,-15.491l86.145,-321.498l57.813,15.491Z"/><path id="rect10" d="M571.479,345.212l-86.145,321.498l-57.813,-15.491l86.145,-321.498l57.813,15.491Z"/><path id="rect8" d="M466.954,350.855l-86.145,321.498l-57.813,-15.491l86.146,-321.498l57.812,15.491Z"/><path id="rect6" d="M370.102,327.865l-86.145,321.498l-57.813,-15.491l86.145,-321.498l57.813,15.491Z"/><path id="rect4" d="M288.594,247.608l-86.145,321.498l-57.813,-15.491l86.145,-321.498l57.813,15.491Z"/><path id="rect2" d="M230.103,81.45l-86.145,321.498l-57.813,-15.49l86.145,-321.499l57.813,15.491Z"/></g></svg>
|
After Width: | Height: | Size: 1.4 KiB |
1
public/images/globe-solid.svg
Normal file
1
public/images/globe-solid.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M352 256c0 22.2-1.2 43.6-3.3 64l-185.3 0c-2.2-20.4-3.3-41.8-3.3-64s1.2-43.6 3.3-64l185.3 0c2.2 20.4 3.3 41.8 3.3 64zm28.8-64l123.1 0c5.3 20.5 8.1 41.9 8.1 64s-2.8 43.5-8.1 64l-123.1 0c2.1-20.6 3.2-42 3.2-64s-1.1-43.4-3.2-64zm112.6-32l-116.7 0c-10-63.9-29.8-117.4-55.3-151.6c78.3 20.7 142 77.5 171.9 151.6zm-149.1 0l-176.6 0c6.1-36.4 15.5-68.6 27-94.7c10.5-23.6 22.2-40.7 33.5-51.5C239.4 3.2 248.7 0 256 0s16.6 3.2 27.8 13.8c11.3 10.8 23 27.9 33.5 51.5c11.6 26 20.9 58.2 27 94.7zm-209 0L18.6 160C48.6 85.9 112.2 29.1 190.6 8.4C165.1 42.6 145.3 96.1 135.3 160zM8.1 192l123.1 0c-2.1 20.6-3.2 42-3.2 64s1.1 43.4 3.2 64L8.1 320C2.8 299.5 0 278.1 0 256s2.8-43.5 8.1-64zM194.7 446.6c-11.6-26-20.9-58.2-27-94.6l176.6 0c-6.1 36.4-15.5 68.6-27 94.6c-10.5 23.6-22.2 40.7-33.5 51.5C272.6 508.8 263.3 512 256 512s-16.6-3.2-27.8-13.8c-11.3-10.8-23-27.9-33.5-51.5zM135.3 352c10 63.9 29.8 117.4 55.3 151.6C112.2 482.9 48.6 426.1 18.6 352l116.7 0zm358.1 0c-30 74.1-93.6 130.9-171.9 151.6c25.5-34.2 45.2-87.7 55.3-151.6l116.7 0z"/></svg>
|
After Width: | Height: | Size: 1.2 KiB |
1
public/images/user-lock-solid.svg
Normal file
1
public/images/user-lock-solid.svg
Normal file
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><!--!Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free Copyright 2024 Fonticons, Inc.--><path d="M224 256A128 128 0 1 0 224 0a128 128 0 1 0 0 256zm-45.7 48C79.8 304 0 383.8 0 482.3C0 498.7 13.3 512 29.7 512l362.8 0c-5.4-9.4-8.6-20.3-8.6-32l0-128c0-2.1 .1-4.2 .3-6.3c-31-26-71-41.7-114.6-41.7l-91.4 0zM528 240c17.7 0 32 14.3 32 32l0 48-64 0 0-48c0-17.7 14.3-32 32-32zm-80 32l0 48c-17.7 0-32 14.3-32 32l0 128c0 17.7 14.3 32 32 32l160 0c17.7 0 32-14.3 32-32l0-128c0-17.7-14.3-32-32-32l0-48c0-44.2-35.8-80-80-80s-80 35.8-80 80z"/></svg>
|
After Width: | Height: | Size: 657 B |
|
@ -49,6 +49,13 @@ class AppInfo {
|
|||
public function getWebsite(): string {
|
||||
return $this->website;
|
||||
}
|
||||
public function getWebsiteDisplay(): string {
|
||||
$website = $this->website;
|
||||
if(str_starts_with($website, 'https://'))
|
||||
$website = substr($website, 8);
|
||||
|
||||
return rtrim($website, '/');
|
||||
}
|
||||
|
||||
public function isTrusted(): bool {
|
||||
return $this->trusted;
|
||||
|
|
|
@ -68,6 +68,10 @@ class HanyuuContext {
|
|||
return $this->authInfo;
|
||||
}
|
||||
|
||||
public function getCSRFPSecret(): string {
|
||||
return $this->config->getString('csrfp:secret', 'beans');
|
||||
}
|
||||
|
||||
public function getWebAssetInfo(): ?object {
|
||||
$path = HAU_DIR_ASSETS . '/current.json';
|
||||
return is_file($path) ? json_decode(file_get_contents($path)) : null;
|
||||
|
@ -81,7 +85,12 @@ class HanyuuContext {
|
|||
$routingCtx = new RoutingContext($this->getTemplating());
|
||||
|
||||
$routingCtx->getRouter()->use('/', function($response, $request) {
|
||||
$this->authInfo = $this->misuzuInterop->authCheck('Misuzu', (string)$request->getCookie('msz_auth'), $_SERVER['REMOTE_ADDR']);
|
||||
$this->authInfo = $this->misuzuInterop->authCheck(
|
||||
'Misuzu',
|
||||
(string)$request->getCookie('msz_auth'),
|
||||
$_SERVER['REMOTE_ADDR'],
|
||||
[60]
|
||||
);
|
||||
});
|
||||
|
||||
$routingCtx->getRouter()->get('/', function($response, $request) {
|
||||
|
@ -93,7 +102,8 @@ class HanyuuContext {
|
|||
$this->oauth2Ctx,
|
||||
$this->appsCtx,
|
||||
$this->templating,
|
||||
$this->getAuthInfo(...)
|
||||
$this->getAuthInfo(...),
|
||||
$this->getCSRFPSecret(...)
|
||||
));
|
||||
|
||||
return $routingCtx;
|
||||
|
|
|
@ -66,9 +66,13 @@ class OAuth2AuthoriseData {
|
|||
string $state,
|
||||
string $challengeCode,
|
||||
string $challengeMethod,
|
||||
string $scope
|
||||
string $scope,
|
||||
?string $code = null,
|
||||
bool $preapprove = false
|
||||
): OAuth2AuthoriseInfo {
|
||||
$stmt = $this->cache->get('INSERT INTO hau_oauth2_authorise (app_id, user_id, uri_id, auth_state, auth_challenge_code, auth_challenge_method, auth_scope, auth_code) VALUES (?, ?, ?, ?, ?, ?, ?, ?)');
|
||||
$code ??= XString::random(60);
|
||||
|
||||
$stmt = $this->cache->get('INSERT INTO hau_oauth2_authorise (app_id, user_id, uri_id, auth_state, auth_challenge_code, auth_challenge_method, auth_scope, auth_code, auth_approval) VALUES (?, ?, ?, ?, ?, ?, ?, ?, IF(?, "approved", DEFAULT(auth_approval)))');
|
||||
$stmt->addParameter(1, $appInfo instanceof AppInfo ? $appInfo->getId() : $appInfo);
|
||||
$stmt->addParameter(2, $userId);
|
||||
$stmt->addParameter(3, $appUriInfo instanceof AppUriInfo ? $appUriInfo->getId() : $appUriInfo);
|
||||
|
@ -76,7 +80,8 @@ class OAuth2AuthoriseData {
|
|||
$stmt->addParameter(5, $challengeCode);
|
||||
$stmt->addParameter(6, $challengeMethod);
|
||||
$stmt->addParameter(7, $scope);
|
||||
$stmt->addParameter(8, XString::random(60));
|
||||
$stmt->addParameter(8, $code);
|
||||
$stmt->addParameter(9, $preapprove ? 1 : 0);
|
||||
$stmt->execute();
|
||||
|
||||
return $this->getAuthoriseInfo(authoriseId: (string)$this->dbConn->getLastInsertId());
|
||||
|
|
|
@ -5,6 +5,7 @@ use InvalidArgumentException;
|
|||
use RuntimeException;
|
||||
use Index\XString;
|
||||
use Index\Http\Routing\{HttpGet,HttpOptions,HttpPost,RouteHandler};
|
||||
use Index\Security\CSRFP;
|
||||
use Sasae\SasaeEnvironment;
|
||||
use Syokuhou\IConfig;
|
||||
use Hanyuu\Apps\AppsContext;
|
||||
|
@ -15,7 +16,8 @@ final class OAuth2Routes extends RouteHandler {
|
|||
private OAuth2Context $oauth2Ctx,
|
||||
private AppsContext $appsCtx,
|
||||
private SasaeEnvironment $templating,
|
||||
private $getAuthInfo
|
||||
private $getAuthInfo,
|
||||
private $getCSRFPSecret
|
||||
) {
|
||||
if(!is_callable($getAuthInfo))
|
||||
throw new InvalidArgumentException('$getAuthInfo must be callable');
|
||||
|
@ -45,9 +47,55 @@ final class OAuth2Routes extends RouteHandler {
|
|||
return $scopes;
|
||||
}
|
||||
|
||||
private const AUTHORISE_ERRORS = [
|
||||
'invalid_request' => [
|
||||
'description' => 'The request is missing a required parameter, includes an invalid parameter value, includes a parameter more than once, or is otherwise malformed.',
|
||||
'allow_description' => true,
|
||||
],
|
||||
'unauthorized_client' => [
|
||||
'description' => 'The client is not authorised to request an authorisation code using this method.',
|
||||
],
|
||||
'access_denied' => [
|
||||
'description' => 'The resource owner or authorization server denied the request.',
|
||||
],
|
||||
'unsupported_response_type' => [
|
||||
'description' => 'The authorisation server does not support obtaining an authorisation code using this method.',
|
||||
],
|
||||
'invalid_scope' => [
|
||||
'description' => 'The requested scope is invalid, unknown, or malformed.',
|
||||
],
|
||||
'server_error' => [
|
||||
'status' => 500,
|
||||
'description' => 'The authorisation server encountered an unexpected condition that prevented it from fulfilling the request.',
|
||||
],
|
||||
'temporarily_unavailable' => [
|
||||
'status' => 503,
|
||||
'description' => 'The authorization server is currently unable to handle the request due to a temporary overloading or maintenance of the server.',
|
||||
],
|
||||
];
|
||||
|
||||
#[HttpGet('/oauth2/error')]
|
||||
public function getError($response, $request) {
|
||||
//
|
||||
$error = (string)$request->getParam('error');
|
||||
if(!array_key_exists($error, self::AUTHORISE_ERRORS))
|
||||
return 404;
|
||||
|
||||
$info = self::AUTHORISE_ERRORS[$error];
|
||||
|
||||
$description = $info['description'];
|
||||
if($request->hasParam('error_description') && array_key_exists('allow_description', $info) && $info['allow_description'])
|
||||
$description = $request->getParam('error_description');
|
||||
|
||||
$statusCode = 400;
|
||||
if(array_key_exists('status', $info))
|
||||
$statusCode = $info['status'];
|
||||
|
||||
$response->setStatusCode($statusCode);
|
||||
|
||||
return $this->templating->render('oauth2/error', [
|
||||
'error_code' => $error,
|
||||
'error_description' => $description,
|
||||
]);
|
||||
}
|
||||
|
||||
#[HttpGet('/oauth2/authorise')]
|
||||
|
@ -172,7 +220,8 @@ final class OAuth2Routes extends RouteHandler {
|
|||
$state,
|
||||
$codeChallenge,
|
||||
$codeChallengeMethod,
|
||||
$scope
|
||||
$scope,
|
||||
preapprove: $appInfo->isTrusted()
|
||||
);
|
||||
} catch(RuntimeException $ex) {
|
||||
return $response->redirect(self::buildCallbackUri($redirectUri, [
|
||||
|
@ -181,28 +230,72 @@ final class OAuth2Routes extends RouteHandler {
|
|||
]));
|
||||
}
|
||||
|
||||
if($authoriseInfo->isApproved()) {
|
||||
$response->redirect(self::buildCallbackUri($redirectUri, [
|
||||
'code' => $authoriseInfo->getCode(),
|
||||
'state' => $authoriseInfo->getState(),
|
||||
]));
|
||||
return;
|
||||
}
|
||||
|
||||
$csrfp = new CSRFP(($this->getCSRFPSecret)(), $authInfo->session->token);
|
||||
|
||||
return $this->templating->render('oauth2/authorise', [
|
||||
'app' => $appInfo,
|
||||
'req' => $authoriseInfo,
|
||||
'auth' => $authInfo,
|
||||
'csrfp_token' => $csrfp->createToken(),
|
||||
'redirect_uri' => $redirectUri,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
#[HttpPost('/oauth2/authorise')]
|
||||
public function postAuthorise($response, $request) {
|
||||
if(!$request->isFormContent())
|
||||
return 400;
|
||||
|
||||
$authInfo = ($this->getAuthInfo)();
|
||||
if(!isset($authInfo->user))
|
||||
return 401;
|
||||
|
||||
// TODO: CSRFP!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
|
||||
$content = $request->getContent();
|
||||
|
||||
$redirectUri = (string)$content->getParam('redirect');
|
||||
if(filter_var($redirectUri, FILTER_VALIDATE_URL) === false) {
|
||||
$response->setStatusCode(400);
|
||||
return [
|
||||
'redirect' => self::buildCallbackUri('', [
|
||||
'error' => 'invalid_request',
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
$authInfo = ($this->getAuthInfo)();
|
||||
if(!isset($authInfo->user)) {
|
||||
$response->setStatusCode(403);
|
||||
return [
|
||||
'redirect' => self::buildCallbackUri($redirectUri, [
|
||||
'error' => 'access_denied',
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
$csrfp = new CSRFP(($this->getCSRFPSecret)(), $authInfo->session->token);
|
||||
if(!$csrfp->verifyToken((string)$content->getParam('csrfp'))) {
|
||||
$response->setStatusCode(403);
|
||||
return [
|
||||
'redirect' => self::buildCallbackUri($redirectUri, [
|
||||
'error' => 'invalid_request',
|
||||
'error_description' => 'Request verification failed.',
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
$approve = (string)$content->getParam('approve');
|
||||
if(!in_array($approve, ['yes', 'no']))
|
||||
return 400;
|
||||
if(!in_array($approve, ['yes', 'no'])) {
|
||||
$response->setStatusCode(400);
|
||||
return [
|
||||
'redirect' => self::buildCallbackUri($redirectUri, [
|
||||
'error' => 'invalid_request',
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
$authoriseData = $this->oauth2Ctx->getAuthoriseData();
|
||||
try {
|
||||
|
@ -211,32 +304,68 @@ final class OAuth2Routes extends RouteHandler {
|
|||
code: (string)$content->getParam('code'),
|
||||
);
|
||||
} catch(RuntimeException $ex) {
|
||||
return 404;
|
||||
$response->setStatusCode(404);
|
||||
return [
|
||||
'redirect' => self::buildCallbackUri($redirectUri, [
|
||||
'error' => 'invalid_request',
|
||||
'error_description' => 'Could not find authorisation request.',
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
if(!$authoriseInfo->isPending())
|
||||
return 410;
|
||||
if(!$authoriseInfo->isPending()) {
|
||||
$response->setStatusCode(410);
|
||||
return [
|
||||
'redirect' => self::buildCallbackUri($redirectUri, [
|
||||
'error' => 'invalid_request',
|
||||
'error_description' => 'This authorisation request has already been handled.',
|
||||
'state' => $authoriseInfo->getState(),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
$appsData = $this->appsCtx->getData();
|
||||
try {
|
||||
$uriInfo = $appsData->getAppUriInfo($authoriseInfo->getUriId());
|
||||
} catch(RuntimeException $ex) {
|
||||
return 400;
|
||||
$response->setStatusCode(400);
|
||||
return [
|
||||
'redirect' => self::buildCallbackUri($redirectUri, [
|
||||
'error' => 'invalid_request',
|
||||
'error_description' => 'This authorisation request was made with a redirect URI that is no longer registered with this application.',
|
||||
'state' => $authoriseInfo->getState(),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
if($uriInfo->getString() !== $redirectUri) {
|
||||
$response->setStatusCode(400);
|
||||
return [
|
||||
'redirect' => self::buildCallbackUri($redirectUri, [
|
||||
'error' => 'invalid_request',
|
||||
'error_description' => 'Attempt at request forgery detected.',
|
||||
'state' => $authoriseInfo->getState(),
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
$approved = $approve === 'yes';
|
||||
$authoriseData->setAuthoriseApproval($authoriseInfo, $approved);
|
||||
|
||||
if($approved)
|
||||
$response->redirect(self::buildCallbackUri($uriInfo->getString(), [
|
||||
'code' => $authoriseInfo->getCode(),
|
||||
'state' => $authoriseInfo->getState(),
|
||||
]));
|
||||
else
|
||||
$response->redirect(self::buildCallbackUri($uriInfo->getString(), [
|
||||
return [
|
||||
'redirect' => self::buildCallbackUri($uriInfo->getString(), [
|
||||
'code' => $authoriseInfo->getCode(),
|
||||
'state' => $authoriseInfo->getState(),
|
||||
]),
|
||||
];
|
||||
|
||||
return [
|
||||
'redirect' => self::buildCallbackUri($uriInfo->getString(), [
|
||||
'error' => 'access_denied',
|
||||
'state' => $authoriseInfo->getState(),
|
||||
]));
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
private static function error(string $code, string $message = '', string $url = ''): array {
|
||||
|
|
|
@ -1,23 +1,87 @@
|
|||
{% extends 'oauth2/master.twig' %}
|
||||
|
||||
{% block body %}
|
||||
<h1><a href="{{ app.website }}" target="_blank">{{ app.name }}</a></h1>
|
||||
<p>{{ app.summary }}</p>
|
||||
<p>{% if app.isTrusted %}This is an official application. Things should probably just implicitly authorise if we hit this.{% else %}This is a third-party application.{% endif %}</p>
|
||||
<header class="oauth2-header"{% if auth.user.colour != 'inherit' %} style="background-color: {{ auth.user.colour }}"{% endif %}>
|
||||
<div class="oauth2-userhead">
|
||||
<div class="oauth2-userhead-main">
|
||||
<div class="oauth2-userhead-main-avatar">
|
||||
<div class="oauth2-userhead-main-avatar-image">
|
||||
<img src="{{ auth.user.avatars.original }}" alt="">
|
||||
</div>
|
||||
</div>
|
||||
<div class="oauth2-userhead-main-name">
|
||||
<a href="{{ auth.user.profile_url }}" target="_blank">{{ auth.user.name }}</a>
|
||||
</div>
|
||||
</div>
|
||||
{% if auth.guise is defined %}
|
||||
<div class="oauth2-userhead-guise">
|
||||
<div class="oauth2-userhead-guise-avatar">
|
||||
<div class="oauth2-userhead-guise-avatar-image">
|
||||
<img src="{{ auth.guise.avatars.x60 }}" alt="">
|
||||
</div>
|
||||
</div>
|
||||
<div class="oauth2-userhead-guise-text">
|
||||
<p>Are you <a href="{{ auth.guise.profile_url }}" target="_blank" style="color: {{ auth.guise.colour }}">{{ auth.guise.name }}</a> and did you mean to use your own account?</p>
|
||||
<p><a href="{{ auth.guise.revert_url }}" target="_blank">Click here</a> and reload this page.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
<div class="oauth2-body">
|
||||
<div class="oauth2-banner">
|
||||
<div class="oauth2-banner-text">
|
||||
Authorisation Request
|
||||
</div>
|
||||
<div class="oauth2-banner-logo">
|
||||
Flashii
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>You are logged in as <a href="{{ auth.user.profile_url }}" target="_blank" style="color: {{ auth.user.colour }}">{{ auth.user.name }}</a>.</p>
|
||||
{% if auth.guise is defined %}
|
||||
<p>Are you <a href="{{ auth.guise.profile_url }}" target="_blank" style="color: {{ auth.guise.colour }}">{{ auth.guise.name }}</a> and did you mean to use your own account? <a href="{{ auth.guise.revert_url }}" target="_blank">Click here</a> and reload this page.</p>
|
||||
{% endif %}
|
||||
<div class="oauth2-authorise-requesting">
|
||||
<p>A third-party application is requesting permission to access your account.</p>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/oauth2/authorise">
|
||||
<input type="hidden" name="code" value="{{ req.code }}">
|
||||
<input type="hidden" name="approve" value="yes">
|
||||
<button>Authorise</button>
|
||||
</form>
|
||||
<form method="post" action="/oauth2/authorise">
|
||||
<input type="hidden" name="code" value="{{ req.code }}">
|
||||
<input type="hidden" name="approve" value="no">
|
||||
<button>Cancel</button>
|
||||
</form>
|
||||
<div class="oauth2-appinfo">
|
||||
<div class="oauth2-appinfo-name">
|
||||
{{ app.name }}
|
||||
</div>{# TODO: author should be listed #}
|
||||
<div class="oauth2-appinfo-links">
|
||||
<a href="{{ app.website }}" target="_blank" rel="noopener noreferrer" class="oauth2-appinfo-link" title="Website">
|
||||
<div class="oauth2-appinfo-link-icon oauth2-appinfo-link-icon-globe"></div>
|
||||
<div class="oauth2-appinfo-link-text">{{ app.websiteDisplay }}</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="oauth2-appinfo-summary">
|
||||
<p>{{ app.summary }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="oauth2-scope">
|
||||
<div class="oauth2-scope-header">This application will be able to:</div>
|
||||
<div class="oauth2-scope-perms">
|
||||
<div class="oauth2-scope-perm">
|
||||
<div class="oauth2-scope-perm-icon"></div>
|
||||
<div class="oauth2-scope-perm-text">Do anything because I have not made up scopes yet.</div>
|
||||
</div>
|
||||
<div class="oauth2-scope-perm">
|
||||
<div class="oauth2-scope-perm-icon"></div>
|
||||
<div class="oauth2-scope-perm-text">Eat soup.</div>
|
||||
</div>
|
||||
<div class="oauth2-scope-perm">
|
||||
<div class="oauth2-scope-perm-icon"></div>
|
||||
<div class="oauth2-scope-perm-text">These are placeholders.</div>
|
||||
</div>
|
||||
<div class="oauth2-scope-perm">
|
||||
<div class="oauth2-scope-perm-icon"></div>
|
||||
<div class="oauth2-scope-perm-text">This one is really long because I want to test wrapping and how the chevron icon thing will handle it so there will be a lot of text here, the app will not be gaining anything from it but yeah sometimes you just need to explode seventy times.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="oauth2-authorise-buttons">
|
||||
<button type="button" class="oauth2-authorise-button oauth2-authorise-button-accept js-authorise-action" data-redirect="{{ redirect_uri }}" data-csrfp="{{ csrfp_token }}" data-approve="yes" data-code="{{ req.code }}" disabled>Authorise</button>
|
||||
<button type="button" class="oauth2-authorise-button oauth2-authorise-button-deny js-authorise-action" data-redirect="{{ redirect_uri }}" data-csrfp="{{ csrfp_token }}" data-approve="no" data-code="{{ req.code }}" disabled>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
15
templates/oauth2/error.twig
Normal file
15
templates/oauth2/error.twig
Normal file
|
@ -0,0 +1,15 @@
|
|||
{% extends 'oauth2/master.twig' %}
|
||||
|
||||
{% block body %}
|
||||
<header class="oauth2-header">
|
||||
<div class="oauth2-errorhead">
|
||||
<div class="oauth2-errorhead-icon"></div>
|
||||
<div class="oauth2-errorhead-text">
|
||||
An error occurred!
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="oauth2-errorbody">
|
||||
<p>{{ error_description }}</p>
|
||||
</div>
|
||||
{% endblock %}
|
|
@ -1,9 +1,71 @@
|
|||
{% extends 'oauth2/master.twig' %}
|
||||
|
||||
{% block body %}
|
||||
<h1><a href="{{ app.website }}" target="_blank">{{ app.name }}</a></h1>
|
||||
<p>{{ app.summary }}</p>
|
||||
<p>{% if app.isTrusted %}This is an official application. Things should probably just implicitly authorise if we hit this.{% else %}This is a third-party application.{% endif %}</p>
|
||||
<header class="oauth2-header">
|
||||
<div class="oauth2-loginhead">
|
||||
<div class="oauth2-loginhead-icon"></div>
|
||||
<div class="oauth2-loginhead-text">
|
||||
Not logged in!
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="oauth2-body">
|
||||
<div class="oauth2-banner">
|
||||
<div class="oauth2-banner-text">
|
||||
Authorisation Request
|
||||
</div>
|
||||
<div class="oauth2-banner-logo">
|
||||
Flashii
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>You must be logged in to authorise applications. <a href="{{ auth.login_url }}" target="_blank">Log in</a> or <a href="{{ auth.register_url }}" target="_blank">create an account</a> and reload this page to try again.</p>
|
||||
<div class="oauth2-authorise-requesting">
|
||||
{% if not app.isTrusted %}
|
||||
<p>A third-party application is requesting permission to access your account.</p>
|
||||
{% endif %}
|
||||
|
||||
<p>You must be logged in to authorise applications. <a href="{{ auth.login_url }}" target="_blank">Log in</a> or <a href="{{ auth.register_url }}" target="_blank">create an account</a> and reload this page to try again.</p>
|
||||
|
||||
{% if app.isTrusted %}
|
||||
<p>You will be redirected to the following application after logging in.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="oauth2-appinfo">
|
||||
<div class="oauth2-appinfo-name">
|
||||
{{ app.name }}
|
||||
</div>{# TODO: author should be listed #}
|
||||
<div class="oauth2-appinfo-links">
|
||||
<a href="{{ app.website }}" target="_blank" rel="noopener noreferrer" class="oauth2-appinfo-link" title="Website">
|
||||
<div class="oauth2-appinfo-link-icon oauth2-appinfo-link-icon-globe"></div>
|
||||
<div class="oauth2-appinfo-link-text">{{ app.websiteDisplay }}</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="oauth2-appinfo-summary">
|
||||
<p>{{ app.summary }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="oauth2-scope">
|
||||
<div class="oauth2-scope-header">This application will be able to:</div>
|
||||
<div class="oauth2-scope-perms">
|
||||
<div class="oauth2-scope-perm">
|
||||
<div class="oauth2-scope-perm-icon"></div>
|
||||
<div class="oauth2-scope-perm-text">Do anything because I have not made up scopes yet.</div>
|
||||
</div>
|
||||
<div class="oauth2-scope-perm">
|
||||
<div class="oauth2-scope-perm-icon"></div>
|
||||
<div class="oauth2-scope-perm-text">Eat soup.</div>
|
||||
</div>
|
||||
<div class="oauth2-scope-perm">
|
||||
<div class="oauth2-scope-perm-icon"></div>
|
||||
<div class="oauth2-scope-perm-text">These are placeholders.</div>
|
||||
</div>
|
||||
<div class="oauth2-scope-perm">
|
||||
<div class="oauth2-scope-perm-icon"></div>
|
||||
<div class="oauth2-scope-perm-text">This one is really long because I want to test wrapping and how the chevron icon thing will handle it so there will be a lot of text here, the app will not be gaining anything from it but yeah sometimes you just need to explode seventy times.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
|
@ -4,10 +4,17 @@
|
|||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||
<title>{% if title is defined %}{{ title }} :: {% endif %}{{ globals.siteInfo.name }}</title>
|
||||
<link href="{{ asset('hanyuu.css') }}" rel="stylesheet">
|
||||
<link href="{{ asset('oauth2.css') }}" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
{% block body %}{% endblock %}
|
||||
<div class="oauth2-wrapper">
|
||||
<div class="oauth2-dialog">
|
||||
<div class="oauth2-dialog-body">
|
||||
{% block body %}{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="{{ asset('oauth2.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
Reference in a new issue