mirror of
https://github.com/donaldzou/WGDashboard.git
synced 2025-06-28 09:16:55 +00:00
Sign In and TOTP is done
This commit is contained in:
parent
0300c26952
commit
832513a7fc
@ -19,7 +19,7 @@ def ResponseObject(status=True, message=None, data=None, status_code = 200) -> F
|
||||
def login_required(f):
|
||||
@wraps(f)
|
||||
def func(*args, **kwargs):
|
||||
if session.get("username") is None or session.get("role") != "client":
|
||||
if session.get("username") is None or session.get("totpVerified") is None or not session.get("totpVerified") or session.get("role") != "client":
|
||||
return ResponseObject(False, "Unauthorized access.", data=None, status_code=401)
|
||||
return f(*args, **kwargs)
|
||||
return func
|
||||
@ -47,12 +47,49 @@ def createClientBlueprint(wireguardConfigurations: dict[WireguardConfiguration],
|
||||
def ClientAPI_SignIn():
|
||||
data = request.json
|
||||
status, msg = DashboardClients.SignIn(**data)
|
||||
if status:
|
||||
session['username'] = data.get('Email')
|
||||
session['role'] = 'client'
|
||||
session['totpVerified'] = False
|
||||
return ResponseObject(status, msg)
|
||||
|
||||
@client.get(f'{prefix}/api/signin/totp')
|
||||
def ClientAPI_SignIn_TOTP():
|
||||
token = request.args.get('Token', None)
|
||||
if not token:
|
||||
return ResponseObject(False, "Please provide TOTP token")
|
||||
|
||||
status, msg = DashboardClients.SignIn_GetTotp(token)
|
||||
return ResponseObject(status, msg)
|
||||
|
||||
@client.post(f'{prefix}/api/signin/totp')
|
||||
def ClientAPI_SignIn_ValidateTOTP():
|
||||
data = request.json
|
||||
token = data.get('Token', None)
|
||||
userProvidedTotp = data.get('UserProvidedTOTP', None)
|
||||
if not all([token, userProvidedTotp]):
|
||||
return ResponseObject(False, "Please fill in all fields")
|
||||
status, msg = DashboardClients.SignIn_GetTotp(token, userProvidedTotp)
|
||||
if status:
|
||||
if session.get('username') is None:
|
||||
return ResponseObject(False, "Sign in status is invalid", status_code=401)
|
||||
session['totpVerified'] = True
|
||||
|
||||
return ResponseObject(status, msg)
|
||||
|
||||
@client.get(prefix)
|
||||
@login_required
|
||||
def ClientIndex():
|
||||
print(wireguardConfigurations.keys())
|
||||
return render_template('client.html')
|
||||
|
||||
@client.get(f'{prefix}/api/validateAuthentication')
|
||||
@login_required
|
||||
def ClientAPI_ValidateAuthentication():
|
||||
return ResponseObject(True)
|
||||
|
||||
@client.get(f'{prefix}/api/configurations')
|
||||
@login_required
|
||||
def ClientAPI_Configurations():
|
||||
return ResponseObject(True, "Ping Pong!")
|
||||
|
||||
return client
|
@ -70,10 +70,35 @@ class DashboardClients:
|
||||
checkPwd = bcrypt.checkpw(Password.encode("utf-8"), existingClient.get("Password").encode("utf-8"))
|
||||
if checkPwd:
|
||||
return True, self.DashboardClientsTOTP.GenerateToken(existingClient.get("ClientID"))
|
||||
|
||||
|
||||
return False, "Email or Password is incorrect"
|
||||
|
||||
def SignIn_GetTotp(self, Token: str, UserProvidedTotp: str = None) -> tuple[bool, str] or tuple[bool, None, str]:
|
||||
status, data = self.DashboardClientsTOTP.GetTotp(Token)
|
||||
if not status:
|
||||
return False, "TOTP Token is invalid"
|
||||
if UserProvidedTotp is None:
|
||||
if data.get('TotpKeyVerified') is None:
|
||||
return True, pyotp.totp.TOTP(data.get('TotpKey')).provisioning_uri(name=data.get('Email'),
|
||||
issuer_name="WGDashboard Client")
|
||||
else:
|
||||
totpMatched = pyotp.TOTP(data.get('TotpKey')).verify(UserProvidedTotp)
|
||||
if not totpMatched:
|
||||
return False, "TOTP is does not match"
|
||||
if data.get('TotpKeyVerified') is None:
|
||||
with self.engine.begin() as conn:
|
||||
conn.execute(
|
||||
self.dashboardClientsTable.update().values({
|
||||
'TotpKeyVerified': 1
|
||||
}).where(
|
||||
self.dashboardClientsTable.c.ClientID == data.get('ClientID')
|
||||
)
|
||||
)
|
||||
return True, None
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def SignUp(self, Email, Password, ConfirmPassword) -> tuple[bool, str] or tuple[bool, None]:
|
||||
try:
|
||||
if not all([Email, Password, ConfirmPassword]):
|
||||
|
@ -19,6 +19,8 @@ class DashboardClientsTOTP:
|
||||
)
|
||||
)
|
||||
self.metadata.create_all(self.engine)
|
||||
self.metadata.reflect(self.engine)
|
||||
self.dashboardClientsTable = self.metadata.tables['DashboardClients']
|
||||
|
||||
def GenerateToken(self, ClientID) -> str:
|
||||
token = hashlib.sha512(f"{ClientID}_{datetime.datetime.now()}_{uuid.uuid4()}".encode()).hexdigest()
|
||||
@ -30,8 +32,6 @@ class DashboardClientsTOTP:
|
||||
db.and_(self.dashboardClientsTOTPTable.c.ClientID == ClientID, self.dashboardClientsTOTPTable.c.ExpireTime > datetime.datetime.now())
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
conn.execute(
|
||||
self.dashboardClientsTOTPTable.insert().values({
|
||||
"Token": token,
|
||||
@ -42,3 +42,28 @@ class DashboardClientsTOTP:
|
||||
|
||||
return token
|
||||
|
||||
def GetTotp(self, token: str) -> tuple[bool, dict] or tuple[bool, None]:
|
||||
with self.engine.connect() as conn:
|
||||
totp = conn.execute(
|
||||
db.select(
|
||||
self.dashboardClientsTable.c.ClientID,
|
||||
self.dashboardClientsTable.c.Email,
|
||||
self.dashboardClientsTable.c.TotpKey,
|
||||
self.dashboardClientsTable.c.TotpKeyVerified,
|
||||
).select_from(
|
||||
self.dashboardClientsTOTPTable
|
||||
).where(
|
||||
db.and_(
|
||||
self.dashboardClientsTOTPTable.c.Token == token,
|
||||
self.dashboardClientsTOTPTable.c.ExpireTime > datetime.datetime.now()
|
||||
)
|
||||
).join(
|
||||
self.dashboardClientsTable,
|
||||
self.dashboardClientsTOTPTable.c.ClientID == self.dashboardClientsTable.c.ClientID
|
||||
)
|
||||
).mappings().fetchone()
|
||||
if totp:
|
||||
return True, dict(totp)
|
||||
return False, None
|
||||
|
||||
|
306
src/static/client/package-lock.json
generated
306
src/static/client/package-lock.json
generated
@ -13,6 +13,7 @@
|
||||
"bootstrap-icons": "^1.13.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"pinia": "^3.0.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^11.1.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.1"
|
||||
@ -1554,6 +1555,30 @@
|
||||
"integrity": "sha512-c/0fWy3Jw6Z8L9FmTyYfkpM5zklnqqa9+a6dz3DvONRKW2NEbh46BP0FHuLFSWi2TnQEtp91Z6zOWNrU6QiyPg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/asynckit": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz",
|
||||
@ -1677,6 +1702,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001720",
|
||||
"resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001720.tgz",
|
||||
@ -1698,6 +1732,35 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
@ -1777,6 +1840,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/default-browser": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/default-browser/-/default-browser-5.2.1.tgz",
|
||||
@ -1829,6 +1901,12 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@ -1850,6 +1928,12 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz",
|
||||
@ -2032,6 +2116,19 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/follow-redirects": {
|
||||
"version": "1.15.9",
|
||||
"resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.9.tgz",
|
||||
@ -2116,6 +2213,15 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
@ -2270,6 +2376,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/is-inside-container": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/is-inside-container/-/is-inside-container-1.0.0.tgz",
|
||||
@ -2416,6 +2531,18 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@ -2562,6 +2689,42 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/parse-ms": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/parse-ms/-/parse-ms-4.0.0.tgz",
|
||||
@ -2575,6 +2738,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz",
|
||||
@ -2638,6 +2810,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.4",
|
||||
"resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.4.tgz",
|
||||
@ -2688,6 +2869,38 @@
|
||||
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmmirror.com/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/rfdc": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz",
|
||||
@ -2757,6 +2970,12 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
@ -2826,6 +3045,32 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-final-newline": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/strip-final-newline/-/strip-final-newline-4.0.0.tgz",
|
||||
@ -3166,6 +3411,32 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz",
|
||||
@ -3173,6 +3444,41 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmmirror.com/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/yoctocolors": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/yoctocolors/-/yoctocolors-2.1.1.tgz",
|
||||
|
@ -14,6 +14,7 @@
|
||||
"bootstrap-icons": "^1.13.1",
|
||||
"dayjs": "^1.11.13",
|
||||
"pinia": "^3.0.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"uuid": "^11.1.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.1"
|
||||
|
@ -16,21 +16,10 @@ import NotificationList from "@/components/notification/notificationList.vue";
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<NotificationList></NotificationList>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-enter-active,
|
||||
.app-leave-active {
|
||||
transition: all 0.3s cubic-bezier(0.82, 0.58, 0.17, 1);
|
||||
}
|
||||
.app-enter-from,
|
||||
.app-leave-to{
|
||||
opacity: 0;
|
||||
filter: blur(5px);
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
@ -73,3 +73,14 @@
|
||||
.slide-right-leave-to{
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.app-enter-active,
|
||||
.app-leave-active {
|
||||
transition: all 0.3s cubic-bezier(0.82, 0.58, 0.17, 1);
|
||||
}
|
||||
.app-enter-from,
|
||||
.app-leave-to{
|
||||
opacity: 0;
|
||||
filter: blur(5px);
|
||||
transform: scale(0.97);
|
||||
}
|
112
src/static/client/src/components/SignIn/signInForm.vue
Normal file
112
src/static/client/src/components/SignIn/signInForm.vue
Normal file
@ -0,0 +1,112 @@
|
||||
<script setup>
|
||||
import {computed, reactive, ref} from "vue";
|
||||
import {clientStore} from "@/stores/clientStore.js";
|
||||
import axios from "axios";
|
||||
import {requestURl} from "@/utilities/request.js";
|
||||
import {useRoute, useRouter} from "vue-router";
|
||||
const loading = ref(false)
|
||||
const formData = reactive({
|
||||
Email: "",
|
||||
Password: ""
|
||||
});
|
||||
const emits = defineEmits(['totpToken'])
|
||||
|
||||
const totpToken = ref("")
|
||||
const store = clientStore()
|
||||
const signIn = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!formFilled){
|
||||
store.newNotification("Please fill in all fields", "warning")
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
await axios.post(requestURl("/client/api/signin"), formData).then(res => {
|
||||
let data = res.data;
|
||||
if (!data.status){
|
||||
store.newNotification(data.message, "danger")
|
||||
loading.value = false;
|
||||
}else{
|
||||
emits("totpToken", data.message)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const formFilled = computed(() => {
|
||||
return Object.values(formData).find(x => !x) === undefined
|
||||
})
|
||||
|
||||
// const router = useRouter()
|
||||
const route = useRoute()
|
||||
if (route.query.Email){
|
||||
formData.Email = route.query.Email
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1>Sign In</h1>
|
||||
<p>to your WGDashboard Client account</p>
|
||||
<form class="mt-4 d-flex flex-column gap-3" @submit="e => signIn(e)">
|
||||
<div class="form-floating">
|
||||
<input type="text"
|
||||
required
|
||||
:disabled="loading"
|
||||
v-model="formData.Email"
|
||||
name="email"
|
||||
autocomplete="email"
|
||||
autofocus
|
||||
class="form-control rounded-3" id="email" placeholder="email">
|
||||
<label for="email" class="d-flex">
|
||||
<i class="bi bi-person-circle me-2"></i>
|
||||
Email
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-floating">
|
||||
<input type="password"
|
||||
required
|
||||
:disabled="loading"
|
||||
v-model="formData.Password"
|
||||
name="password"
|
||||
autocomplete="current-password"
|
||||
class="form-control rounded-3" id="password" placeholder="Password">
|
||||
<label for="password" class="d-flex">
|
||||
<i class="bi bi-key me-2"></i>
|
||||
Password
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<a href="#" class="text-body text-decoration-none ms-0">
|
||||
Forgot Password?
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
:disabled="!formFilled || loading"
|
||||
class="btn btn-primary rounded-3 btn-brand px-3 py-2">
|
||||
<Transition name="slide-right" mode="out-in">
|
||||
<span v-if="!loading" class="d-block">
|
||||
Continue <i class="ms-2 bi bi-arrow-right"></i>
|
||||
</span>
|
||||
<span v-else class="d-block">
|
||||
Loading...
|
||||
<i class="spinner-border spinner-border-sm"></i>
|
||||
</span>
|
||||
</Transition>
|
||||
</button>
|
||||
</form>
|
||||
<div>
|
||||
<hr class="my-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="text-muted">
|
||||
Don't have an account yet?
|
||||
</span>
|
||||
<RouterLink to="/signup" class="text-body text-decoration-none ms-auto fw-bold">
|
||||
Sign Up
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
136
src/static/client/src/components/SignIn/totpForm.vue
Normal file
136
src/static/client/src/components/SignIn/totpForm.vue
Normal file
@ -0,0 +1,136 @@
|
||||
<script setup async>
|
||||
import {computed, onMounted, reactive, ref} from "vue";
|
||||
import axios from "axios";
|
||||
import {requestURl} from "@/utilities/request.js";
|
||||
import {useRouter} from "vue-router";
|
||||
import {clientStore} from "@/stores/clientStore.js";
|
||||
import QRCode from "qrcode";
|
||||
|
||||
const props = defineProps([
|
||||
'totpToken'
|
||||
])
|
||||
const totpKey = ref("")
|
||||
const formData = reactive({
|
||||
TOTP: ""
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const replace = () => {
|
||||
formData.TOTP = formData.TOTP.replace(/\D/i, "")
|
||||
}
|
||||
const formFilled = computed(() => {
|
||||
return /^[0-9]{6}$/.test(formData.TOTP)
|
||||
})
|
||||
|
||||
const store = clientStore()
|
||||
const router = useRouter()
|
||||
|
||||
await axios.get(requestURl('/client/api/signin/totp'), {
|
||||
params: {
|
||||
Token: props.totpToken
|
||||
}
|
||||
}).then(res => {
|
||||
let data = res.data
|
||||
if (data.status){
|
||||
if (data.message){
|
||||
totpKey.value = data.message
|
||||
}
|
||||
}else{
|
||||
store.newNotification(data.message, "danger")
|
||||
router.push('/signin')
|
||||
}
|
||||
})
|
||||
|
||||
const emits = defineEmits(['clearToken'])
|
||||
|
||||
onMounted(() => {
|
||||
if (totpKey.value){
|
||||
QRCode.toCanvas(document.getElementById('qrcode'), totpKey.value, function (error) {})
|
||||
}
|
||||
})
|
||||
|
||||
const verify = async (e) => {
|
||||
e.preventDefault()
|
||||
if (formFilled){
|
||||
loading.value = true
|
||||
await axios.post(requestURl('/client/api/signin/totp'), {
|
||||
Token: props.totpToken,
|
||||
UserProvidedTOTP: formData.TOTP
|
||||
}).then(res => {
|
||||
loading.value = false
|
||||
let data = res.data
|
||||
if (data.status){
|
||||
router.push('/')
|
||||
}else{
|
||||
store.newNotification(data.message, "danger")
|
||||
}
|
||||
}).catch(() => {
|
||||
store.newNotification("Sign in status is invalid", "danger")
|
||||
emits('clearToken')
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="d-flex flex-column gap-3" @submit="e => verify(e)">
|
||||
<div>
|
||||
<a role="button" @click="emits('clearToken')">
|
||||
<i class="me-2 bi bi-chevron-left"></i> Back
|
||||
</a>
|
||||
</div>
|
||||
<div class="">
|
||||
<h1 class="mb-3">Multi-Factor Authentication (MFA)</h1>
|
||||
<div class="card rounded-3" v-if="totpKey">
|
||||
<div class="card-body d-flex gap-3 flex-column">
|
||||
<h2 class="mb-0">Initial Setup</h2>
|
||||
<p class="mb-0">Please scan the following QR Code to generate TOTP with your choice of authenticator</p>
|
||||
<canvas id="qrcode" class="rounded-3 shadow "></canvas>
|
||||
<p class="mb-0">Or you can click the link below:</p>
|
||||
<div class="card rounded-3 ">
|
||||
<div class="card-body">
|
||||
<a :href="totpKey">
|
||||
{{ totpKey }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-warning mb-0">
|
||||
<strong>
|
||||
Please note: You won't be able to see this QR Code again, so please save it somewhere safe in case you need to recover your TOTP key
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr v-if="totpKey">
|
||||
<div class="d-flex flex-column gap-3">
|
||||
<label for="totp">Enter the TOTP generated by your authenticator to verify</label>
|
||||
<input class="form-control form-control-lg rounded-3 text-center"
|
||||
id="totp"
|
||||
:disabled="loading"
|
||||
autofocus
|
||||
@keyup="replace()"
|
||||
maxlength="6" type="text" inputmode="numeric"
|
||||
placeholder="- - - - - -"
|
||||
autocomplete="one-time-code" v-model="formData.TOTP">
|
||||
|
||||
<button
|
||||
:disabled="!formFilled || loading"
|
||||
class="btn btn-primary rounded-3 btn-brand px-3 py-2">
|
||||
<Transition name="slide-right" mode="out-in">
|
||||
<span v-if="!loading" class="d-block">
|
||||
Continue <i class="ms-2 bi bi-arrow-right"></i>
|
||||
</span>
|
||||
<span v-else class="d-block">
|
||||
<i class="spinner-border spinner-border-sm"></i>
|
||||
</span>
|
||||
</Transition>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
@ -2,7 +2,9 @@ import {createWebHashHistory, createRouter} from "vue-router";
|
||||
import Index from "@/views/index.vue";
|
||||
import SignIn from "@/views/signin.vue";
|
||||
import SignUp from "@/views/signup.vue";
|
||||
import Totp from "@/views/totp.vue";
|
||||
import axios from "axios";
|
||||
import {requestURl} from "@/utilities/request.js";
|
||||
import {clientStore} from "@/stores/clientStore.js";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
@ -10,6 +12,9 @@ const router = createRouter({
|
||||
{
|
||||
path: '/',
|
||||
component: Index,
|
||||
meta: {
|
||||
auth: true
|
||||
},
|
||||
name: "Home"
|
||||
},
|
||||
{
|
||||
@ -21,15 +26,24 @@ const router = createRouter({
|
||||
path: '/signup',
|
||||
component: SignUp,
|
||||
name: "Sign Up"
|
||||
},
|
||||
{
|
||||
path: '/totp',
|
||||
component: Totp,
|
||||
name: "Verify TOTP"
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
if (to.meta.auth){
|
||||
await axios.get(requestURl('/client/api/validateAuthentication')).then(res => {
|
||||
next()
|
||||
}).catch(() => {
|
||||
const store = clientStore()
|
||||
store.newNotification("Sign in session ended, please sign in again", "warning")
|
||||
next('/signin')
|
||||
})
|
||||
}else{
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
router.afterEach((to, from, next) => {
|
||||
document.title = to.name + ' | WGDashboard Client'
|
||||
})
|
||||
|
@ -1,107 +1,23 @@
|
||||
<script setup>
|
||||
import {computed, reactive, ref} from "vue";
|
||||
import {clientStore} from "@/stores/clientStore.js";
|
||||
import axios from "axios";
|
||||
import {requestURl} from "@/utilities/request.js";
|
||||
import {useRoute, useRouter} from "vue-router";
|
||||
const loading = ref(false)
|
||||
const formData = reactive({
|
||||
Email: "",
|
||||
Password: ""
|
||||
});
|
||||
const store = clientStore()
|
||||
const signIn = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!formFilled){
|
||||
store.newNotification("Please fill in all fields", "warning")
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
await axios.post(requestURl("/client/api/signin"), formData).then(res => {
|
||||
let data = res.data;
|
||||
if (!data.status){
|
||||
store.newNotification(data.message, "danger")
|
||||
loading.value = false;
|
||||
}else{
|
||||
import SignInForm from "@/components/SignIn/signInForm.vue";
|
||||
import {ref} from "vue";
|
||||
import TotpForm from "@/components/SignIn/totpForm.vue";
|
||||
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const formFilled = computed(() => {
|
||||
return Object.values(formData).find(x => !x) === undefined
|
||||
})
|
||||
|
||||
// const router = useRouter()
|
||||
const route = useRoute()
|
||||
if (route.query.Email){
|
||||
formData.Email = route.query.Email
|
||||
}
|
||||
const checkTotp = ref("")
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1>Sign In</h1>
|
||||
<p>to your WGDashboard Client account</p>
|
||||
<form class="mt-4 d-flex flex-column gap-3" @submit="e => signIn(e)">
|
||||
<div class="form-floating">
|
||||
<input type="text"
|
||||
required
|
||||
:disabled="loading"
|
||||
v-model="formData.Email"
|
||||
name="email"
|
||||
autocomplete="email"
|
||||
autofocus
|
||||
class="form-control rounded-3" id="email" placeholder="email">
|
||||
<label for="email" class="d-flex">
|
||||
<i class="bi bi-person-circle me-2"></i>
|
||||
Email
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-floating">
|
||||
<input type="password"
|
||||
required
|
||||
:disabled="loading"
|
||||
v-model="formData.Password"
|
||||
name="password"
|
||||
autocomplete="current-password"
|
||||
class="form-control rounded-3" id="password" placeholder="Password">
|
||||
<label for="password" class="d-flex">
|
||||
<i class="bi bi-key me-2"></i>
|
||||
Password
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<a href="#" class="text-body text-decoration-none ms-0">
|
||||
Forgot Password?
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
:disabled="!formFilled"
|
||||
class="btn btn-primary rounded-3 btn-brand px-3 py-2">
|
||||
<Transition name="slide-right" mode="out-in">
|
||||
<span v-if="!loading" class="d-block">
|
||||
Continue <i class="ms-2 bi bi-arrow-right"></i>
|
||||
</span>
|
||||
<span v-else class="d-block">
|
||||
<i class="spinner-border spinner-border-sm"></i>
|
||||
</span>
|
||||
<Transition name="app" mode="out-in">
|
||||
<SignInForm
|
||||
@totpToken="token => { checkTotp = token }"
|
||||
v-if="!checkTotp"></SignInForm>
|
||||
<TotpForm
|
||||
@clearToken="checkTotp = ''"
|
||||
:totp-token="checkTotp"
|
||||
v-else></TotpForm>
|
||||
</Transition>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div>
|
||||
<hr class="my-4">
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="text-muted">
|
||||
Don't have an account yet?
|
||||
</span>
|
||||
<RouterLink to="/signup" class="text-body text-decoration-none ms-auto fw-bold">
|
||||
Sign Up
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -127,6 +127,7 @@ onMounted(() => {
|
||||
Continue <i class="ms-2 bi bi-arrow-right"></i>
|
||||
</span>
|
||||
<span v-else class="d-block">
|
||||
Loading...
|
||||
<i class="spinner-border spinner-border-sm"></i>
|
||||
</span>
|
||||
</Transition>
|
||||
|
@ -1,11 +0,0 @@
|
||||
<script setup>
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
Loading…
x
Reference in New Issue
Block a user