Compare commits
19 Commits
feat/new-u
...
v3.4.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2c81b6a5c | ||
|
|
34c8d16c7d | ||
|
|
75f07a9d7f | ||
|
|
74f1c10826 | ||
|
|
168467d648 | ||
|
|
d527900991 | ||
|
|
f3acec7daf | ||
|
|
bbaa036d85 | ||
|
|
fc73e25d51 | ||
|
|
e50ffe7907 | ||
|
|
dda6a8c47f | ||
|
|
eba2df3dd9 | ||
|
|
11731f78d1 | ||
|
|
3a7b71ae3e | ||
|
|
3c3bd719db | ||
|
|
a6aa97bcfa | ||
|
|
1a7b6cfb99 | ||
|
|
da7cebdfed | ||
|
|
318f00993e |
@@ -28,4 +28,6 @@ LOGIN_MAX_RETRIES=5
|
|||||||
LOG_LEVEL=0
|
LOG_LEVEL=0
|
||||||
APP_TITLE=Tinyauth SSO
|
APP_TITLE=Tinyauth SSO
|
||||||
FORGOT_PASSWORD_MESSAGE=Some message about resetting the password
|
FORGOT_PASSWORD_MESSAGE=Some message about resetting the password
|
||||||
OAUTH_AUTO_REDIRECT=none
|
OAUTH_AUTO_REDIRECT=none
|
||||||
|
BACKGROUND_IMAGE=some_image_url
|
||||||
|
GENERIC_SKIP_SSL=false
|
||||||
2
.github/workflows/release.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
|||||||
- name: Generate metadata
|
- name: Generate metadata
|
||||||
id: metadata
|
id: metadata
|
||||||
run: |
|
run: |
|
||||||
echo "VERSION=nightly" >> "$GITHUB_OUTPUT"
|
echo "VERSION=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
|
||||||
echo "COMMIT_HASH=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
echo "COMMIT_HASH=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||||
echo "BUILD_TIMESTAMP=$(date '+%Y-%m-%dT%H:%M:%S')" >> "$GITHUB_OUTPUT"
|
echo "BUILD_TIMESTAMP=$(date '+%Y-%m-%dT%H:%M:%S')" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
|||||||
98
.github/workflows/translations.yml
vendored
@@ -1,98 +0,0 @@
|
|||||||
name: Publish translations
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- i18n_v*
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pages: write
|
|
||||||
id-token: write
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: pages
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
get-branches:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
i18n-branches: ${{ steps.get-branches.outputs.result }}
|
|
||||||
steps:
|
|
||||||
- name: Get branches
|
|
||||||
id: get-branches
|
|
||||||
uses: actions/github-script@v7
|
|
||||||
with:
|
|
||||||
script: |
|
|
||||||
const { data: repos } = await github.rest.repos.listBranches({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
})
|
|
||||||
|
|
||||||
const i18nBranches = repos.filter((branch) => branch.name.startsWith("i18n_v"))
|
|
||||||
const i18nBranchNames = i18nBranches.map((branch) => branch.name)
|
|
||||||
|
|
||||||
return i18nBranchNames
|
|
||||||
|
|
||||||
get-translations:
|
|
||||||
needs: get-branches
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
branch: ${{ fromJson(needs.get-branches.outputs.i18n-branches) }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
ref: ${{ matrix.branch }}
|
|
||||||
|
|
||||||
- name: Get translation version
|
|
||||||
id: get-version
|
|
||||||
run: |
|
|
||||||
branch=${{ matrix.branch }}
|
|
||||||
version=${branch#i18n_}
|
|
||||||
echo "version=$version" >> $GITHUB_OUTPUT
|
|
||||||
|
|
||||||
- name: Upload translations
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
with:
|
|
||||||
name: ${{ steps.get-version.outputs.version }}
|
|
||||||
path: frontend/src/lib/i18n/locales
|
|
||||||
|
|
||||||
build:
|
|
||||||
needs: get-translations
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Pages
|
|
||||||
uses: actions/configure-pages@v4
|
|
||||||
|
|
||||||
- name: Prepare output directory
|
|
||||||
run: |
|
|
||||||
mkdir -p dist/i18n/
|
|
||||||
|
|
||||||
- name: Download translations
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
path: dist/i18n/
|
|
||||||
|
|
||||||
- name: Upload artifact
|
|
||||||
uses: actions/upload-pages-artifact@v3
|
|
||||||
with:
|
|
||||||
path: dist
|
|
||||||
|
|
||||||
deploy:
|
|
||||||
environment:
|
|
||||||
name: github-pages
|
|
||||||
url: ${{ steps.deployment.outputs.page_url }}
|
|
||||||
needs: build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
name: Deploy
|
|
||||||
steps:
|
|
||||||
- name: Deploy to GitHub Pages
|
|
||||||
id: deployment
|
|
||||||
uses: actions/deploy-pages@v4
|
|
||||||
17
Dockerfile
@@ -1,15 +1,10 @@
|
|||||||
# Arguments
|
|
||||||
ARG VERSION
|
|
||||||
ARG COMMIT_HASH
|
|
||||||
ARG BUILD_TIMESTAMP
|
|
||||||
|
|
||||||
# Site builder
|
# Site builder
|
||||||
FROM oven/bun:1.2.12-alpine AS frontend-builder
|
FROM oven/bun:1.2.15-alpine AS frontend-builder
|
||||||
|
|
||||||
WORKDIR /frontend
|
WORKDIR /frontend
|
||||||
|
|
||||||
COPY ./frontend/package.json ./
|
COPY ./frontend/package.json ./
|
||||||
COPY ./frontend/bun.lockb ./
|
COPY ./frontend/bun.lock ./
|
||||||
|
|
||||||
RUN bun install
|
RUN bun install
|
||||||
|
|
||||||
@@ -21,13 +16,16 @@ COPY ./frontend/tsconfig.json ./
|
|||||||
COPY ./frontend/tsconfig.app.json ./
|
COPY ./frontend/tsconfig.app.json ./
|
||||||
COPY ./frontend/tsconfig.node.json ./
|
COPY ./frontend/tsconfig.node.json ./
|
||||||
COPY ./frontend/vite.config.ts ./
|
COPY ./frontend/vite.config.ts ./
|
||||||
COPY ./frontend/postcss.config.cjs ./
|
|
||||||
|
|
||||||
RUN bun run build
|
RUN bun run build
|
||||||
|
|
||||||
# Builder
|
# Builder
|
||||||
FROM golang:1.24-alpine3.21 AS builder
|
FROM golang:1.24-alpine3.21 AS builder
|
||||||
|
|
||||||
|
ARG VERSION
|
||||||
|
ARG COMMIT_HASH
|
||||||
|
ARG BUILD_TIMESTAMP
|
||||||
|
|
||||||
WORKDIR /tinyauth
|
WORKDIR /tinyauth
|
||||||
|
|
||||||
COPY go.mod ./
|
COPY go.mod ./
|
||||||
@@ -53,7 +51,4 @@ COPY --from=builder /tinyauth/tinyauth ./
|
|||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
HEALTHCHECK --interval=10s --timeout=5s \
|
|
||||||
CMD curl -f http://localhost:3000/api/healthcheck || exit 1
|
|
||||||
|
|
||||||
ENTRYPOINT ["./tinyauth"]
|
ENTRYPOINT ["./tinyauth"]
|
||||||
24
README.md
@@ -1,5 +1,5 @@
|
|||||||
<div align="center">
|
<div align="center">
|
||||||
<img alt="Tinyauth" title="Tinyauth" height="256" src="frontend/public/logo.png">
|
<img alt="Tinyauth" title="Tinyauth" width="96" src="assets/logo-rounded.png">
|
||||||
<h1>Tinyauth</h1>
|
<h1>Tinyauth</h1>
|
||||||
<p>The easiest way to secure your apps with a login screen.</p>
|
<p>The easiest way to secure your apps with a login screen.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -14,27 +14,31 @@
|
|||||||
|
|
||||||
<br />
|
<br />
|
||||||
|
|
||||||
Tinyauth is a simple authentication middleware that adds simple username/password login or OAuth with Google, Github and any generic provider to all of your docker apps. It is designed for traefik but it can be extended to work with all reverse proxies like caddy and nginx.
|
Tinyauth is a simple authentication middleware that adds simple username/password login or OAuth with Google, Github and any generic provider to all of your docker apps. It is designed for traefik but it can be extended to work with other reverse proxies like caddy and nginx.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
> [!WARNING]
|
> [!WARNING]
|
||||||
> Tinyauth is in active development and configuration may change often. Please make sure to carefully read the release notes before updating.
|
> Tinyauth is in active development and configuration may change often. Please make sure to carefully read the release notes before updating.
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> Tinyauth is intended for homelab use and it is not made for production use cases. If you are looking for something production ready please use [authentik](https://goauthentik.io).
|
> Tinyauth is intended for homelab use only and it is not made for production use cases. If you are looking for something production ready please use [authentik](https://goauthentik.io) instead.
|
||||||
|
|
||||||
## Discord
|
|
||||||
|
|
||||||
I just made a Discord server for Tinyauth! It is not only for Tinyauth but general self-hosting because I just like chatting with people! The link is [here](https://discord.gg/eHzVaCzRRd), see you there!
|
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
You can easily get started with tinyauth by following the guide in the [documentation](https://tinyauth.app/docs/getting-started.html). There is also an available [docker compose file](./docker-compose.example.yml) that has traefik, whoami and tinyauth to demonstrate its capabilities.
|
You can easily get started with tinyauth by following the guide in the [documentation](https://tinyauth.app/docs/getting-started.html). There is also an available [docker compose file](./docker-compose.example.yml) that has traefik, whoami and tinyauth to demonstrate its capabilities.
|
||||||
|
|
||||||
|
## Demo
|
||||||
|
|
||||||
|
If you are still not sure if tinyauth suits your needs you can try out the [demo](https://demo.tinyauth.app). The default username is `user` and the default password is `password`.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
You can find documentation and guides on all of the available configuration of tinyauth [here](https://tinyauth.app).
|
You can find documentation and guides on all of the available configuration of tinyauth in the [website](https://tinyauth.app).
|
||||||
|
|
||||||
|
## Discord
|
||||||
|
|
||||||
|
I just made a Discord server for tinyauth! It is not only for tinyauth but general self-hosting and homelabbing. [See you there!](https://discord.gg/eHzVaCzRRd).
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
@@ -60,6 +64,8 @@ Credits for the logo of this app go to:
|
|||||||
|
|
||||||
- **Freepik** for providing the police hat and badge.
|
- **Freepik** for providing the police hat and badge.
|
||||||
- **Renee French** for the original gopher logo.
|
- **Renee French** for the original gopher logo.
|
||||||
|
- **Coderabbit AI** for providing free AI code reviews.
|
||||||
|
- **Syrhu** for providing the bacgkround image of the app.
|
||||||
|
|
||||||
## Star History
|
## Star History
|
||||||
|
|
||||||
|
|||||||
BIN
assets/logo-rounded.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
assets/logo-solid.jpg
Normal file
|
After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 35 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 4.5 MiB |
32
cmd/root.go
@@ -2,6 +2,7 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -67,6 +68,12 @@ var rootCmd = &cobra.Command{
|
|||||||
HandleError(err, "Failed to get upper domain")
|
HandleError(err, "Failed to get upper domain")
|
||||||
log.Info().Str("domain", domain).Msg("Using domain for cookie store")
|
log.Info().Str("domain", domain).Msg("Using domain for cookie store")
|
||||||
|
|
||||||
|
// Generate cookie name
|
||||||
|
cookieId := utils.GenerateIdentifier(strings.Split(domain, ".")[0])
|
||||||
|
sessionCookieName := fmt.Sprintf("%s-%s", constants.SessionCookieName, cookieId)
|
||||||
|
csrfCookieName := fmt.Sprintf("%s-%s", constants.CsrfCookieName, cookieId)
|
||||||
|
redirectCookieName := fmt.Sprintf("%s-%s", constants.RedirectCookieName, cookieId)
|
||||||
|
|
||||||
// Create OAuth config
|
// Create OAuth config
|
||||||
oauthConfig := types.OAuthConfig{
|
oauthConfig := types.OAuthConfig{
|
||||||
GithubClientId: config.GithubClientId,
|
GithubClientId: config.GithubClientId,
|
||||||
@@ -79,6 +86,7 @@ var rootCmd = &cobra.Command{
|
|||||||
GenericAuthURL: config.GenericAuthURL,
|
GenericAuthURL: config.GenericAuthURL,
|
||||||
GenericTokenURL: config.GenericTokenURL,
|
GenericTokenURL: config.GenericTokenURL,
|
||||||
GenericUserURL: config.GenericUserURL,
|
GenericUserURL: config.GenericUserURL,
|
||||||
|
GenericSkipSSL: config.GenericSkipSSL,
|
||||||
AppURL: config.AppURL,
|
AppURL: config.AppURL,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +99,10 @@ var rootCmd = &cobra.Command{
|
|||||||
CookieSecure: config.CookieSecure,
|
CookieSecure: config.CookieSecure,
|
||||||
Domain: domain,
|
Domain: domain,
|
||||||
ForgotPasswordMessage: config.FogotPasswordMessage,
|
ForgotPasswordMessage: config.FogotPasswordMessage,
|
||||||
|
BackgroundImage: config.BackgroundImage,
|
||||||
OAuthAutoRedirect: config.OAuthAutoRedirect,
|
OAuthAutoRedirect: config.OAuthAutoRedirect,
|
||||||
|
CsrfCookieName: csrfCookieName,
|
||||||
|
RedirectCookieName: redirectCookieName,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create api config
|
// Create api config
|
||||||
@@ -102,14 +113,15 @@ var rootCmd = &cobra.Command{
|
|||||||
|
|
||||||
// Create auth config
|
// Create auth config
|
||||||
authConfig := types.AuthConfig{
|
authConfig := types.AuthConfig{
|
||||||
Users: users,
|
Users: users,
|
||||||
OauthWhitelist: config.OAuthWhitelist,
|
OauthWhitelist: config.OAuthWhitelist,
|
||||||
Secret: config.Secret,
|
Secret: config.Secret,
|
||||||
CookieSecure: config.CookieSecure,
|
CookieSecure: config.CookieSecure,
|
||||||
SessionExpiry: config.SessionExpiry,
|
SessionExpiry: config.SessionExpiry,
|
||||||
Domain: domain,
|
Domain: domain,
|
||||||
LoginTimeout: config.LoginTimeout,
|
LoginTimeout: config.LoginTimeout,
|
||||||
LoginMaxRetries: config.LoginMaxRetries,
|
LoginMaxRetries: config.LoginMaxRetries,
|
||||||
|
SessionCookieName: sessionCookieName,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create hooks config
|
// Create hooks config
|
||||||
@@ -196,6 +208,7 @@ func init() {
|
|||||||
rootCmd.Flags().String("generic-token-url", "", "Generic OAuth token URL.")
|
rootCmd.Flags().String("generic-token-url", "", "Generic OAuth token URL.")
|
||||||
rootCmd.Flags().String("generic-user-url", "", "Generic OAuth user info URL.")
|
rootCmd.Flags().String("generic-user-url", "", "Generic OAuth user info URL.")
|
||||||
rootCmd.Flags().String("generic-name", "Generic", "Generic OAuth provider name.")
|
rootCmd.Flags().String("generic-name", "Generic", "Generic OAuth provider name.")
|
||||||
|
rootCmd.Flags().Bool("generic-skip-ssl", false, "Skip SSL verification for the generic OAuth provider.")
|
||||||
rootCmd.Flags().Bool("disable-continue", false, "Disable continue screen and redirect to app directly.")
|
rootCmd.Flags().Bool("disable-continue", false, "Disable continue screen and redirect to app directly.")
|
||||||
rootCmd.Flags().String("oauth-whitelist", "", "Comma separated list of email addresses to whitelist when using OAuth.")
|
rootCmd.Flags().String("oauth-whitelist", "", "Comma separated list of email addresses to whitelist when using OAuth.")
|
||||||
rootCmd.Flags().String("oauth-auto-redirect", "none", "Auto redirect to the specified OAuth provider if configured. (available providers: github, google, generic)")
|
rootCmd.Flags().String("oauth-auto-redirect", "none", "Auto redirect to the specified OAuth provider if configured. (available providers: github, google, generic)")
|
||||||
@@ -205,6 +218,7 @@ func init() {
|
|||||||
rootCmd.Flags().Int("log-level", 1, "Log level.")
|
rootCmd.Flags().Int("log-level", 1, "Log level.")
|
||||||
rootCmd.Flags().String("app-title", "Tinyauth", "Title of the app.")
|
rootCmd.Flags().String("app-title", "Tinyauth", "Title of the app.")
|
||||||
rootCmd.Flags().String("forgot-password-message", "You can reset your password by changing the `USERS` environment variable.", "Message to show on the forgot password page.")
|
rootCmd.Flags().String("forgot-password-message", "You can reset your password by changing the `USERS` environment variable.", "Message to show on the forgot password page.")
|
||||||
|
rootCmd.Flags().String("background-image", "/background.jpg", "Background image URL for the login page.")
|
||||||
|
|
||||||
// Bind flags to environment
|
// Bind flags to environment
|
||||||
viper.BindEnv("port", "PORT")
|
viper.BindEnv("port", "PORT")
|
||||||
@@ -229,6 +243,7 @@ func init() {
|
|||||||
viper.BindEnv("generic-token-url", "GENERIC_TOKEN_URL")
|
viper.BindEnv("generic-token-url", "GENERIC_TOKEN_URL")
|
||||||
viper.BindEnv("generic-user-url", "GENERIC_USER_URL")
|
viper.BindEnv("generic-user-url", "GENERIC_USER_URL")
|
||||||
viper.BindEnv("generic-name", "GENERIC_NAME")
|
viper.BindEnv("generic-name", "GENERIC_NAME")
|
||||||
|
viper.BindEnv("generic-skip-ssl", "GENERIC_SKIP_SSL")
|
||||||
viper.BindEnv("disable-continue", "DISABLE_CONTINUE")
|
viper.BindEnv("disable-continue", "DISABLE_CONTINUE")
|
||||||
viper.BindEnv("oauth-whitelist", "OAUTH_WHITELIST")
|
viper.BindEnv("oauth-whitelist", "OAUTH_WHITELIST")
|
||||||
viper.BindEnv("oauth-auto-redirect", "OAUTH_AUTO_REDIRECT")
|
viper.BindEnv("oauth-auto-redirect", "OAUTH_AUTO_REDIRECT")
|
||||||
@@ -238,6 +253,7 @@ func init() {
|
|||||||
viper.BindEnv("login-timeout", "LOGIN_TIMEOUT")
|
viper.BindEnv("login-timeout", "LOGIN_TIMEOUT")
|
||||||
viper.BindEnv("login-max-retries", "LOGIN_MAX_RETRIES")
|
viper.BindEnv("login-max-retries", "LOGIN_MAX_RETRIES")
|
||||||
viper.BindEnv("forgot-password-message", "FORGOT_PASSWORD_MESSAGE")
|
viper.BindEnv("forgot-password-message", "FORGOT_PASSWORD_MESSAGE")
|
||||||
|
viper.BindEnv("background-image", "BACKGROUND_IMAGE")
|
||||||
|
|
||||||
// Bind flags to viper
|
// Bind flags to viper
|
||||||
viper.BindPFlags(rootCmd.Flags())
|
viper.BindPFlags(rootCmd.Flags())
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
# Ignore artifacts:
|
# Ignore artifacts:
|
||||||
dist
|
dist
|
||||||
node_modules
|
node_modules
|
||||||
|
bun.lock
|
||||||
|
package.json
|
||||||
|
src/lib/i18n/locales
|
||||||
@@ -3,7 +3,7 @@ FROM oven/bun:1.1.45-alpine
|
|||||||
WORKDIR /frontend
|
WORKDIR /frontend
|
||||||
|
|
||||||
COPY ./frontend/package.json ./
|
COPY ./frontend/package.json ./
|
||||||
COPY ./frontend/bun.lockb ./
|
COPY ./frontend/bun.lock ./
|
||||||
|
|
||||||
RUN bun install
|
RUN bun install
|
||||||
|
|
||||||
@@ -16,7 +16,6 @@ COPY ./frontend/tsconfig.json ./
|
|||||||
COPY ./frontend/tsconfig.app.json ./
|
COPY ./frontend/tsconfig.app.json ./
|
||||||
COPY ./frontend/tsconfig.node.json ./
|
COPY ./frontend/tsconfig.node.json ./
|
||||||
COPY ./frontend/vite.config.ts ./
|
COPY ./frontend/vite.config.ts ./
|
||||||
COPY ./frontend/postcss.config.cjs ./
|
|
||||||
|
|
||||||
EXPOSE 5173
|
EXPOSE 5173
|
||||||
|
|
||||||
|
|||||||
1055
frontend/bun.lock
Normal file
21
frontend/components.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "new-york",
|
||||||
|
"rsc": false,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "src/index.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide"
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import globals from "globals";
|
|||||||
import reactHooks from "eslint-plugin-react-hooks";
|
import reactHooks from "eslint-plugin-react-hooks";
|
||||||
import reactRefresh from "eslint-plugin-react-refresh";
|
import reactRefresh from "eslint-plugin-react-refresh";
|
||||||
import tseslint from "typescript-eslint";
|
import tseslint from "typescript-eslint";
|
||||||
|
import pluginQuery from "@tanstack/eslint-plugin-query";
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{ ignores: ["dist"] },
|
{ ignores: ["dist"] },
|
||||||
@@ -16,6 +17,7 @@ export default tseslint.config(
|
|||||||
plugins: {
|
plugins: {
|
||||||
"react-hooks": reactHooks,
|
"react-hooks": reactHooks,
|
||||||
"react-refresh": reactRefresh,
|
"react-refresh": reactRefresh,
|
||||||
|
"@tanstack/query": pluginQuery,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
...reactHooks.configs.recommended.rules,
|
...reactHooks.configs.recommended.rules,
|
||||||
@@ -23,6 +25,7 @@ export default tseslint.config(
|
|||||||
"warn",
|
"warn",
|
||||||
{ allowConstantExport: true },
|
{ allowConstantExport: true },
|
||||||
],
|
],
|
||||||
|
"@tanstack/query/exhaustive-deps": "error",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,13 +3,15 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
|
<meta name="apple-mobile-web-app-title" content="Tinyauth" />
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
|
<link rel="manifest" href="/site.webmanifest" />
|
||||||
<link rel="manifest" href="/frontend.webmanifest" />
|
|
||||||
<title>Tinyauth</title>
|
<title>Tinyauth</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="dark">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
4919
frontend/package-lock.json
generated
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "tinyauth-shadcn",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -10,39 +10,49 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mantine/core": "^8.0.0",
|
"@hookform/resolvers": "^5.0.1",
|
||||||
"@mantine/form": "^8.0.0",
|
"@radix-ui/react-label": "^2.1.7",
|
||||||
"@mantine/hooks": "^8.0.0",
|
"@radix-ui/react-select": "^2.2.5",
|
||||||
"@mantine/notifications": "^8.0.0",
|
"@radix-ui/react-separator": "^1.1.7",
|
||||||
"@tanstack/react-query": "5",
|
"@radix-ui/react-slot": "^1.2.3",
|
||||||
"axios": "^1.7.9",
|
"@tailwindcss/vite": "^4.1.8",
|
||||||
"i18next": "^25.0.0",
|
"@tanstack/react-query": "^5.79.0",
|
||||||
"i18next-browser-languagedetector": "^8.0.4",
|
"axios": "^1.9.0",
|
||||||
"i18next-chained-backend": "^4.6.2",
|
"class-variance-authority": "^0.7.1",
|
||||||
"i18next-http-backend": "^3.0.2",
|
"clsx": "^2.1.1",
|
||||||
|
"dompurify": "^3.2.6",
|
||||||
|
"i18next": "^25.2.1",
|
||||||
|
"i18next-browser-languagedetector": "^8.0.5",
|
||||||
"i18next-resources-to-backend": "^1.2.1",
|
"i18next-resources-to-backend": "^1.2.1",
|
||||||
"react": "^19.1.0",
|
"input-otp": "^1.4.2",
|
||||||
"react-dom": "^19.1.0",
|
"lucide-react": "^0.511.0",
|
||||||
"react-i18next": "^15.4.1",
|
"next-themes": "^0.4.6",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-hook-form": "^7.56.4",
|
||||||
|
"react-i18next": "^15.5.2",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router": "^7.5.2",
|
"react-router": "^7.6.1",
|
||||||
"zod": "^3.24.1"
|
"sonner": "^2.0.3",
|
||||||
|
"tailwind-merge": "^3.3.0",
|
||||||
|
"tailwindcss": "^4.1.8",
|
||||||
|
"zod": "^3.25.42"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.17.0",
|
"@eslint/js": "^9.27.0",
|
||||||
"@types/react": "^19.1.1",
|
"@tanstack/eslint-plugin-query": "^5.78.0",
|
||||||
"@types/react-dom": "^19.1.2",
|
"@types/node": "^22.15.27",
|
||||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
"@types/react": "^19.1.6",
|
||||||
"eslint": "^9.17.0",
|
"@types/react-dom": "^19.1.5",
|
||||||
"eslint-plugin-react-hooks": "^5.0.0",
|
"@vitejs/plugin-react": "^4.5.0",
|
||||||
"eslint-plugin-react-refresh": "^0.4.16",
|
"eslint": "^9.27.0",
|
||||||
"globals": "^16.0.0",
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
"postcss": "^8.5.1",
|
"eslint-plugin-react-refresh": "^0.4.19",
|
||||||
"postcss-preset-mantine": "^1.17.0",
|
"globals": "^16.2.0",
|
||||||
"postcss-simple-vars": "^7.0.1",
|
|
||||||
"prettier": "3.5.3",
|
"prettier": "3.5.3",
|
||||||
|
"tw-animate-css": "^1.3.2",
|
||||||
"typescript": "~5.8.3",
|
"typescript": "~5.8.3",
|
||||||
"typescript-eslint": "^8.18.2",
|
"typescript-eslint": "^8.33.0",
|
||||||
"vite": "^6.3.4"
|
"vite": "^6.3.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
"postcss-preset-mantine": {},
|
|
||||||
"postcss-simple-vars": {
|
|
||||||
variables: {
|
|
||||||
"mantine-breakpoint-xs": "36em",
|
|
||||||
"mantine-breakpoint-sm": "48em",
|
|
||||||
"mantine-breakpoint-md": "62em",
|
|
||||||
"mantine-breakpoint-lg": "75em",
|
|
||||||
"mantine-breakpoint-xl": "88em",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 9.6 KiB |
BIN
frontend/public/background.jpg
Normal file
|
After Width: | Height: | Size: 530 KiB |
|
Before Width: | Height: | Size: 602 B |
|
Before Width: | Height: | Size: 1.4 KiB |
BIN
frontend/public/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
3
frontend/public/favicon.svg
Normal file
|
After Width: | Height: | Size: 48 KiB |
@@ -1 +1,21 @@
|
|||||||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
{
|
||||||
|
"name": "Tinyauth",
|
||||||
|
"short_name": "Tinyauth",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/web-app-manifest-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#171717",
|
||||||
|
"background_color": "#171717",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
||||||
|
|||||||
BIN
frontend/public/web-app-manifest-192x192.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
frontend/public/web-app-manifest-512x512.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
@@ -1,17 +1,12 @@
|
|||||||
import { Navigate } from "react-router";
|
import { Navigate } from "react-router";
|
||||||
import { useUserContext } from "./context/user-context";
|
import { useUserContext } from "./context/user-context";
|
||||||
import { LogoutPage } from "./pages/logout-page";
|
|
||||||
|
|
||||||
export const App = () => {
|
export const App = () => {
|
||||||
const queryString = window.location.search;
|
|
||||||
const params = new URLSearchParams(queryString);
|
|
||||||
const redirectUri = params.get("redirect_uri");
|
|
||||||
|
|
||||||
const { isLoggedIn } = useUserContext();
|
const { isLoggedIn } = useUserContext();
|
||||||
|
|
||||||
if (!isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
return <Navigate to={`/login?redirect_uri=${redirectUri}`} />;
|
return <Navigate to="/logout" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <LogoutPage />;
|
return <Navigate to="/login" />;
|
||||||
};
|
};
|
||||||
|
|||||||
81
frontend/src/components/auth/login-form.tsx
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Input } from "../ui/input";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "../ui/form";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
|
import { loginSchema, LoginSchema } from "@/schemas/login-schema";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onSubmit: (data: LoginSchema) => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LoginForm = (props: Props) => {
|
||||||
|
const { onSubmit, loading } = props;
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const form = useForm<LoginSchema>({
|
||||||
|
resolver: zodResolver(loginSchema),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="username"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="mb-4">
|
||||||
|
<FormLabel>{t("loginUsername")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder={t("loginUsername")}
|
||||||
|
disabled={loading}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="password"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem className="mb-4">
|
||||||
|
<div className="relative">
|
||||||
|
<FormLabel className="mb-2">{t("loginPassword")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder={t("loginPassword")}
|
||||||
|
type="password"
|
||||||
|
disabled={loading}
|
||||||
|
{...field}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
<a
|
||||||
|
href="/forgot-password"
|
||||||
|
className="text-muted-foreground text-sm absolute right-0 bottom-10"
|
||||||
|
>
|
||||||
|
{t("forgotPasswordTitle")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Button className="w-full" type="submit" loading={loading}>
|
||||||
|
{t("loginSubmit")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
import { TextInput, PasswordInput, Button, Anchor, Group, Text } from "@mantine/core";
|
|
||||||
import { useForm, zodResolver } from "@mantine/form";
|
|
||||||
import { LoginFormValues, loginSchema } from "../../schemas/login-schema";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
interface LoginFormProps {
|
|
||||||
isPending: boolean;
|
|
||||||
onSubmit: (values: LoginFormValues) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const LoginForm = (props: LoginFormProps) => {
|
|
||||||
const { isPending, onSubmit } = props;
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
const form = useForm({
|
|
||||||
mode: "uncontrolled",
|
|
||||||
initialValues: {
|
|
||||||
username: "",
|
|
||||||
password: "",
|
|
||||||
},
|
|
||||||
validate: zodResolver(loginSchema),
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
|
||||||
<TextInput
|
|
||||||
label={t("loginUsername")}
|
|
||||||
placeholder="Username"
|
|
||||||
disabled={isPending}
|
|
||||||
required
|
|
||||||
withAsterisk={false}
|
|
||||||
key={form.key("username")}
|
|
||||||
{...form.getInputProps("username")}
|
|
||||||
/>
|
|
||||||
<Group justify="space-between" mb={5} mt="md">
|
|
||||||
<Text component="label" htmlFor=".password-input" size="sm" fw={500}>
|
|
||||||
{t("loginPassword")}
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
<Anchor href="#" onClick={() => window.location.replace("/forgot-password")} pt={2} fw={500} fz="xs">
|
|
||||||
{t('forgotPasswordTitle')}
|
|
||||||
</Anchor>
|
|
||||||
</Group>
|
|
||||||
<PasswordInput
|
|
||||||
className="password-input"
|
|
||||||
placeholder="Password"
|
|
||||||
required
|
|
||||||
disabled={isPending}
|
|
||||||
key={form.key("password")}
|
|
||||||
{...form.getInputProps("password")}
|
|
||||||
/>
|
|
||||||
<Button fullWidth mt="xl" type="submit" loading={isPending}>
|
|
||||||
{t("loginSubmit")}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { Grid, Button } from "@mantine/core";
|
|
||||||
import { GithubIcon } from "../../icons/github";
|
|
||||||
import { GoogleIcon } from "../../icons/google";
|
|
||||||
import { OAuthIcon } from "../../icons/oauth";
|
|
||||||
|
|
||||||
interface OAuthButtonsProps {
|
|
||||||
oauthProviders: string[];
|
|
||||||
isPending: boolean;
|
|
||||||
mutate: (provider: string) => void;
|
|
||||||
genericName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const OAuthButtons = (props: OAuthButtonsProps) => {
|
|
||||||
const { oauthProviders, isPending, genericName, mutate } = props;
|
|
||||||
return (
|
|
||||||
<Grid mb="md" mt="md" align="center" justify="center">
|
|
||||||
{oauthProviders.includes("google") && (
|
|
||||||
<Grid.Col span="content">
|
|
||||||
<Button
|
|
||||||
radius="xl"
|
|
||||||
leftSection={<GoogleIcon style={{ width: 14, height: 14 }} />}
|
|
||||||
variant="default"
|
|
||||||
onClick={() => mutate("google")}
|
|
||||||
loading={isPending}
|
|
||||||
>
|
|
||||||
Google
|
|
||||||
</Button>
|
|
||||||
</Grid.Col>
|
|
||||||
)}
|
|
||||||
{oauthProviders.includes("github") && (
|
|
||||||
<Grid.Col span="content">
|
|
||||||
<Button
|
|
||||||
radius="xl"
|
|
||||||
leftSection={<GithubIcon style={{ width: 14, height: 14 }} />}
|
|
||||||
variant="default"
|
|
||||||
onClick={() => mutate("github")}
|
|
||||||
loading={isPending}
|
|
||||||
>
|
|
||||||
Github
|
|
||||||
</Button>
|
|
||||||
</Grid.Col>
|
|
||||||
)}
|
|
||||||
{oauthProviders.includes("generic") && (
|
|
||||||
<Grid.Col span="content">
|
|
||||||
<Button
|
|
||||||
radius="xl"
|
|
||||||
leftSection={<OAuthIcon style={{ width: 14, height: 14 }} />}
|
|
||||||
variant="default"
|
|
||||||
onClick={() => mutate("generic")}
|
|
||||||
loading={isPending}
|
|
||||||
>
|
|
||||||
{genericName}
|
|
||||||
</Button>
|
|
||||||
</Grid.Col>
|
|
||||||
)}
|
|
||||||
</Grid>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,40 +1,54 @@
|
|||||||
import { Button, PinInput } from "@mantine/core";
|
import { Form, FormControl, FormField, FormItem } from "../ui/form";
|
||||||
import { useForm, zodResolver } from "@mantine/form";
|
import {
|
||||||
import { z } from "zod";
|
InputOTP,
|
||||||
|
InputOTPGroup,
|
||||||
|
InputOTPSeparator,
|
||||||
|
InputOTPSlot,
|
||||||
|
} from "../ui/input-otp";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { totpSchema, TotpSchema } from "@/schemas/totp-schema";
|
||||||
|
|
||||||
const schema = z.object({
|
interface Props {
|
||||||
code: z.string(),
|
formId: string;
|
||||||
});
|
onSubmit: (code: TotpSchema) => void;
|
||||||
|
loading?: boolean;
|
||||||
type FormValues = z.infer<typeof schema>;
|
|
||||||
|
|
||||||
interface TotpFormProps {
|
|
||||||
onSubmit: (values: FormValues) => void;
|
|
||||||
isPending: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TotpForm = (props: TotpFormProps) => {
|
export const TotpForm = (props: Props) => {
|
||||||
const { onSubmit, isPending } = props;
|
const { formId, onSubmit, loading } = props;
|
||||||
|
|
||||||
const form = useForm({
|
const form = useForm<TotpSchema>({
|
||||||
mode: "uncontrolled",
|
resolver: zodResolver(totpSchema),
|
||||||
initialValues: {
|
|
||||||
code: "",
|
|
||||||
},
|
|
||||||
validate: zodResolver(schema),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={form.onSubmit(onSubmit)}>
|
<Form {...form}>
|
||||||
<PinInput
|
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
length={6}
|
<FormField
|
||||||
type={"number"}
|
control={form.control}
|
||||||
placeholder=""
|
name="code"
|
||||||
{...form.getInputProps("code")}
|
render={({ field }) => (
|
||||||
/>
|
<FormItem>
|
||||||
<Button type="submit" mt="xl" loading={isPending} fullWidth>
|
<FormControl>
|
||||||
Verify
|
<InputOTP maxLength={6} disabled={loading} {...field}>
|
||||||
</Button>
|
<InputOTPGroup>
|
||||||
</form>
|
<InputOTPSlot index={0} />
|
||||||
|
<InputOTPSlot index={1} />
|
||||||
|
<InputOTPSlot index={2} />
|
||||||
|
</InputOTPGroup>
|
||||||
|
<InputOTPSeparator />
|
||||||
|
<InputOTPGroup>
|
||||||
|
<InputOTPSlot index={3} />
|
||||||
|
<InputOTPSlot index={4} />
|
||||||
|
<InputOTPSlot index={5} />
|
||||||
|
</InputOTPGroup>
|
||||||
|
</InputOTP>
|
||||||
|
</FormControl>
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { SVGProps } from "react";
|
import type { SVGProps } from "react";
|
||||||
|
|
||||||
export function OAuthIcon(props: SVGProps<SVGSVGElement>) {
|
export function GenericIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
30
frontend/src/components/icons/google.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import type { SVGProps } from "react";
|
||||||
|
|
||||||
|
export function GoogleIcon(props: SVGProps<SVGSVGElement>) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={256}
|
||||||
|
height={262}
|
||||||
|
viewBox="0 0 256 262"
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="#4285f4"
|
||||||
|
d="M255.878 133.451c0-10.734-.871-18.567-2.756-26.69H130.55v48.448h71.947c-1.45 12.04-9.283 30.172-26.69 42.356l-.244 1.622l38.755 30.023l2.685.268c24.659-22.774 38.875-56.282 38.875-96.027"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
fill="#34a853"
|
||||||
|
d="M130.55 261.1c35.248 0 64.839-11.605 86.453-31.622l-41.196-31.913c-11.024 7.688-25.82 13.055-45.257 13.055c-34.523 0-63.824-22.773-74.269-54.25l-1.531.13l-40.298 31.187l-.527 1.465C35.393 231.798 79.49 261.1 130.55 261.1"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
fill="#fbbc05"
|
||||||
|
d="M56.281 156.37c-2.756-8.123-4.351-16.827-4.351-25.82c0-8.994 1.595-17.697 4.206-25.82l-.073-1.73L15.26 71.312l-1.335.635C5.077 89.644 0 109.517 0 130.55s5.077 40.905 13.925 58.602z"
|
||||||
|
></path>
|
||||||
|
<path
|
||||||
|
fill="#eb4335"
|
||||||
|
d="M130.55 50.479c24.514 0 41.05 10.589 50.479 19.438l36.844-35.974C195.245 12.91 165.798 0 130.55 0C79.49 0 35.393 29.301 13.925 71.947l42.211 32.783c10.59-31.477 39.891-54.251 74.414-54.251"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { ComboboxItem, Select } from "@mantine/core";
|
|
||||||
import { useState } from "react";
|
|
||||||
import i18n from "../../lib/i18n/i18n";
|
|
||||||
import {
|
|
||||||
SupportedLanguage,
|
|
||||||
getLanguageName,
|
|
||||||
languages,
|
|
||||||
} from "../../lib/i18n/locales";
|
|
||||||
|
|
||||||
export const LanguageSelector = () => {
|
|
||||||
const [language, setLanguage] = useState<ComboboxItem>({
|
|
||||||
value: i18n.language,
|
|
||||||
label: getLanguageName(i18n.language as SupportedLanguage),
|
|
||||||
});
|
|
||||||
|
|
||||||
const languageOptions = Object.entries(languages).map(([code, name]) => ({
|
|
||||||
value: code,
|
|
||||||
label: name,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const handleLanguageChange = (option: string) => {
|
|
||||||
i18n.changeLanguage(option as SupportedLanguage);
|
|
||||||
setLanguage({
|
|
||||||
value: option,
|
|
||||||
label: getLanguageName(option as SupportedLanguage),
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Select
|
|
||||||
data={languageOptions}
|
|
||||||
value={language ? language.value : null}
|
|
||||||
onChange={(_value, option) => handleLanguageChange(option.value)}
|
|
||||||
allowDeselect={false}
|
|
||||||
pos="absolute"
|
|
||||||
right={10}
|
|
||||||
top={10}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
35
frontend/src/components/language/language.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { languages, SupportedLanguage } from "@/lib/i18n/locales";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "../ui/select";
|
||||||
|
import { useState } from "react";
|
||||||
|
import i18n from "@/lib/i18n/i18n";
|
||||||
|
|
||||||
|
export const LanguageSelector = () => {
|
||||||
|
const [language, setLanguage] = useState<SupportedLanguage>(
|
||||||
|
i18n.language as SupportedLanguage,
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelect = (option: string) => {
|
||||||
|
setLanguage(option as SupportedLanguage);
|
||||||
|
i18n.changeLanguage(option as SupportedLanguage);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Select onValueChange={handleSelect} value={language}>
|
||||||
|
<SelectTrigger className="absolute top-5 right-5">
|
||||||
|
<SelectValue placeholder="Select language" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Object.entries(languages).map(([key, value]) => (
|
||||||
|
<SelectItem key={key} value={key}>
|
||||||
|
{value}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
};
|
||||||
21
frontend/src/components/layout/layout.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { useAppContext } from "@/context/app-context";
|
||||||
|
import { LanguageSelector } from "../language/language";
|
||||||
|
import { Outlet } from "react-router";
|
||||||
|
|
||||||
|
export const Layout = () => {
|
||||||
|
const { backgroundImage } = useAppContext();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative flex flex-col justify-center items-center min-h-svh"
|
||||||
|
style={{
|
||||||
|
backgroundImage: `url(${backgroundImage})`,
|
||||||
|
backgroundSize: "cover",
|
||||||
|
backgroundPosition: "center",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<LanguageSelector />
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import { Center, Flex } from "@mantine/core";
|
|
||||||
import { ReactNode } from "react";
|
|
||||||
import { LanguageSelector } from "../language-selector/language-selector";
|
|
||||||
|
|
||||||
export const Layout = ({ children }: { children: ReactNode }) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<LanguageSelector />
|
|
||||||
<Center style={{ minHeight: "100vh" }}>
|
|
||||||
<Flex direction="column" flex="1" maw={340}>
|
|
||||||
{children}
|
|
||||||
</Flex>
|
|
||||||
</Center>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
77
frontend/src/components/ui/button.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
const buttonVariants = cva(
|
||||||
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default:
|
||||||
|
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
|
||||||
|
destructive:
|
||||||
|
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
|
outline:
|
||||||
|
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||||
|
secondary:
|
||||||
|
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
|
||||||
|
ghost:
|
||||||
|
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||||
|
link: "text-primary underline-offset-4 hover:underline",
|
||||||
|
warning:
|
||||||
|
"bg-amber-500 text-white shadow-xs hover:bg-amber-400 focus-visible:ring-amber-200/20 dark:focus-visible:ring-amber-400/40 dark:bg-amber-600",
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||||
|
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||||
|
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||||
|
icon: "size-9",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
size: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
function Button({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
size,
|
||||||
|
asChild = false,
|
||||||
|
loading = false,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"button"> &
|
||||||
|
VariantProps<typeof buttonVariants> & {
|
||||||
|
asChild?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
}) {
|
||||||
|
const Comp = asChild ? Slot : "button";
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
disabled
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
|
</Comp>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
data-slot="button"
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Button, buttonVariants };
|
||||||
92
frontend/src/components/ui/card.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card"
|
||||||
|
className={cn(
|
||||||
|
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-header"
|
||||||
|
className={cn(
|
||||||
|
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-title"
|
||||||
|
className={cn("leading-none font-semibold", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-description"
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-action"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-content"
|
||||||
|
className={cn("px-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="card-footer"
|
||||||
|
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardFooter,
|
||||||
|
CardTitle,
|
||||||
|
CardAction,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
};
|
||||||
166
frontend/src/components/ui/form.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
FormProvider,
|
||||||
|
useFormContext,
|
||||||
|
useFormState,
|
||||||
|
type ControllerProps,
|
||||||
|
type FieldPath,
|
||||||
|
type FieldValues,
|
||||||
|
} from "react-hook-form";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
|
||||||
|
const Form = FormProvider;
|
||||||
|
|
||||||
|
type FormFieldContextValue<
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
> = {
|
||||||
|
name: TName;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||||
|
{} as FormFieldContextValue,
|
||||||
|
);
|
||||||
|
|
||||||
|
const FormField = <
|
||||||
|
TFieldValues extends FieldValues = FieldValues,
|
||||||
|
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
|
||||||
|
>({
|
||||||
|
...props
|
||||||
|
}: ControllerProps<TFieldValues, TName>) => {
|
||||||
|
return (
|
||||||
|
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||||
|
<Controller {...props} />
|
||||||
|
</FormFieldContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const useFormField = () => {
|
||||||
|
const fieldContext = React.useContext(FormFieldContext);
|
||||||
|
const itemContext = React.useContext(FormItemContext);
|
||||||
|
const { getFieldState } = useFormContext();
|
||||||
|
const formState = useFormState({ name: fieldContext.name });
|
||||||
|
const fieldState = getFieldState(fieldContext.name, formState);
|
||||||
|
|
||||||
|
if (!fieldContext) {
|
||||||
|
throw new Error("useFormField should be used within <FormField>");
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = itemContext;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: fieldContext.name,
|
||||||
|
formItemId: `${id}-form-item`,
|
||||||
|
formDescriptionId: `${id}-form-item-description`,
|
||||||
|
formMessageId: `${id}-form-item-message`,
|
||||||
|
...fieldState,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type FormItemContextValue = {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||||
|
{} as FormItemContextValue,
|
||||||
|
);
|
||||||
|
|
||||||
|
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
const id = React.useId();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormItemContext.Provider value={{ id }}>
|
||||||
|
<div
|
||||||
|
data-slot="form-item"
|
||||||
|
className={cn("grid gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</FormItemContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
const { error, formItemId } = useFormField();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Label
|
||||||
|
data-slot="form-label"
|
||||||
|
data-error={!!error}
|
||||||
|
className={cn("data-[error=true]:text-destructive", className)}
|
||||||
|
htmlFor={formItemId}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
|
||||||
|
const { error, formItemId, formDescriptionId, formMessageId } =
|
||||||
|
useFormField();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slot
|
||||||
|
data-slot="form-control"
|
||||||
|
id={formItemId}
|
||||||
|
aria-describedby={
|
||||||
|
!error
|
||||||
|
? `${formDescriptionId}`
|
||||||
|
: `${formDescriptionId} ${formMessageId}`
|
||||||
|
}
|
||||||
|
aria-invalid={!!error}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
const { formDescriptionId } = useFormField();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="form-description"
|
||||||
|
id={formDescriptionId}
|
||||||
|
className={cn("text-muted-foreground text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
|
||||||
|
const { error, formMessageId } = useFormField();
|
||||||
|
const body = error ? String(error?.message ?? "") : props.children;
|
||||||
|
|
||||||
|
if (!body) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
data-slot="form-message"
|
||||||
|
id={formMessageId}
|
||||||
|
className={cn("text-destructive text-sm", className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
useFormField,
|
||||||
|
Form,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormMessage,
|
||||||
|
FormField,
|
||||||
|
};
|
||||||
75
frontend/src/components/ui/input-otp.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { OTPInput, OTPInputContext } from "input-otp";
|
||||||
|
import { MinusIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function InputOTP({
|
||||||
|
className,
|
||||||
|
containerClassName,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof OTPInput> & {
|
||||||
|
containerClassName?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<OTPInput
|
||||||
|
data-slot="input-otp"
|
||||||
|
containerClassName={cn(
|
||||||
|
"flex items-center gap-2 has-disabled:opacity-50",
|
||||||
|
containerClassName,
|
||||||
|
)}
|
||||||
|
className={cn("disabled:cursor-not-allowed", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-otp-group"
|
||||||
|
className={cn("flex items-center", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPSlot({
|
||||||
|
index,
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & {
|
||||||
|
index: number;
|
||||||
|
}) {
|
||||||
|
const inputOTPContext = React.useContext(OTPInputContext);
|
||||||
|
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="input-otp-slot"
|
||||||
|
data-active={isActive}
|
||||||
|
className={cn(
|
||||||
|
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{char}
|
||||||
|
{hasFakeCaret && (
|
||||||
|
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div data-slot="input-otp-separator" role="separator" {...props}>
|
||||||
|
<MinusIcon />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };
|
||||||
21
frontend/src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
data-slot="input"
|
||||||
|
className={cn(
|
||||||
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||||
|
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Input };
|
||||||
22
frontend/src/components/ui/label.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as LabelPrimitive from "@radix-ui/react-label";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Label({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
data-slot="label"
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Label };
|
||||||
33
frontend/src/components/ui/oauth-button.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { Button } from "./button";
|
||||||
|
import React from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
interface Props extends React.ComponentProps<typeof Button> {
|
||||||
|
title: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
loading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OAuthButton = (props: Props) => {
|
||||||
|
const { title, icon, onClick, loading, className, ...rest } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
onClick={onClick}
|
||||||
|
className={twMerge("rounded-md", className)}
|
||||||
|
variant="outline"
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<Loader2 className="animate-spin" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{icon}
|
||||||
|
{title}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
183
frontend/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||||
|
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Select({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
|
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectGroup({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectValue({
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectTrigger({
|
||||||
|
className,
|
||||||
|
size = "default",
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
|
size?: "sm" | "default";
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
data-slot="select-trigger"
|
||||||
|
data-size={size}
|
||||||
|
className={cn(
|
||||||
|
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-card dark:hover:bg-card/90 flex w-fit items-center justify-between gap-2 rounded-md border bg-card hover:bg-card/90 px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectContent({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
position = "popper",
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
data-slot="select-content"
|
||||||
|
className={cn(
|
||||||
|
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||||
|
position === "popper" &&
|
||||||
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
"p-1",
|
||||||
|
position === "popper" &&
|
||||||
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectLabel({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
data-slot="select-label"
|
||||||
|
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectItem({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<CheckIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectSeparator({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
data-slot="select-separator"
|
||||||
|
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollUpButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
data-slot="select-scroll-up-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUpIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SelectScrollDownButton({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
data-slot="select-scroll-down-button"
|
||||||
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectLabel,
|
||||||
|
SelectScrollDownButton,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
};
|
||||||
38
frontend/src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function Separator({
|
||||||
|
className,
|
||||||
|
orientation = "horizontal",
|
||||||
|
decorative = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<SeparatorPrimitive.Root
|
||||||
|
data-slot="separator-root"
|
||||||
|
decorative={decorative}
|
||||||
|
orientation={orientation}
|
||||||
|
className={cn(
|
||||||
|
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SeperatorWithChildren({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Separator className="flex-1" />
|
||||||
|
<span className="text-sm text-muted-foreground">{children}</span>
|
||||||
|
<Separator className="flex-1" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Separator, SeperatorWithChildren };
|
||||||
23
frontend/src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { useTheme } from "next-themes";
|
||||||
|
import { Toaster as Sonner, ToasterProps } from "sonner";
|
||||||
|
|
||||||
|
const Toaster = ({ ...props }: ToasterProps) => {
|
||||||
|
const { theme = "system" } = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sonner
|
||||||
|
theme={theme as ToasterProps["theme"]}
|
||||||
|
className="toaster group"
|
||||||
|
style={
|
||||||
|
{
|
||||||
|
"--normal-bg": "var(--popover)",
|
||||||
|
"--normal-text": "var(--popover-foreground)",
|
||||||
|
"--normal-border": "var(--border)",
|
||||||
|
} as React.CSSProperties
|
||||||
|
}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export { Toaster };
|
||||||
@@ -1,40 +1,42 @@
|
|||||||
|
import {
|
||||||
|
appContextSchema,
|
||||||
|
AppContextSchema,
|
||||||
|
} from "@/schemas/app-context-schema";
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||||
import React, { createContext, useContext } from "react";
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { AppContextSchemaType } from "../schemas/app-context-schema";
|
|
||||||
|
|
||||||
const AppContext = createContext<AppContextSchemaType | null>(null);
|
const AppContext = createContext<AppContextSchema | null>(null);
|
||||||
|
|
||||||
export const AppContextProvider = ({
|
export const AppContextProvider = ({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const { isFetching, data, error } = useSuspenseQuery({
|
||||||
data: userContext,
|
queryKey: ["app"],
|
||||||
isLoading,
|
queryFn: () => axios.get("/api/app").then((res) => res.data),
|
||||||
error,
|
|
||||||
} = useSuspenseQuery({
|
|
||||||
queryKey: ["appContext"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await axios.get("/api/app");
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error && !isLoading) {
|
if (error && !isFetching) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const validated = appContextSchema.safeParse(data);
|
||||||
|
|
||||||
|
if (validated.success === false) {
|
||||||
|
throw validated.error;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppContext.Provider value={userContext}>{children}</AppContext.Provider>
|
<AppContext.Provider value={validated.data}>{children}</AppContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useAppContext = () => {
|
export const useAppContext = () => {
|
||||||
const context = useContext(AppContext);
|
const context = useContext(AppContext);
|
||||||
|
|
||||||
if (context === null) {
|
if (!context) {
|
||||||
throw new Error("useAppContext must be used within an AppContextProvider");
|
throw new Error("useAppContext must be used within an AppContextProvider");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,41 +1,47 @@
|
|||||||
|
import {
|
||||||
|
userContextSchema,
|
||||||
|
UserContextSchema,
|
||||||
|
} from "@/schemas/user-context-schema";
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||||
import React, { createContext, useContext } from "react";
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { UserContextSchemaType } from "../schemas/user-context-schema";
|
|
||||||
|
|
||||||
const UserContext = createContext<UserContextSchemaType | null>(null);
|
const UserContext = createContext<UserContextSchema | null>(null);
|
||||||
|
|
||||||
export const UserContextProvider = ({
|
export const UserContextProvider = ({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const { isFetching, data, error } = useSuspenseQuery({
|
||||||
data: userContext,
|
queryKey: ["user"],
|
||||||
isLoading,
|
queryFn: () => axios.get("/api/user").then((res) => res.data),
|
||||||
error,
|
|
||||||
} = useSuspenseQuery({
|
|
||||||
queryKey: ["userContext"],
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await axios.get("/api/user");
|
|
||||||
return res.data;
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error && !isLoading) {
|
if (error && !isFetching) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const validated = userContextSchema.safeParse(data);
|
||||||
|
|
||||||
|
if (validated.success === false) {
|
||||||
|
throw validated.error;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<UserContext.Provider value={userContext}>{children}</UserContext.Provider>
|
<UserContext.Provider value={validated.data}>
|
||||||
|
{children}
|
||||||
|
</UserContext.Provider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useUserContext = () => {
|
export const useUserContext = () => {
|
||||||
const context = useContext(UserContext);
|
const context = useContext(UserContext);
|
||||||
|
|
||||||
if (context === null) {
|
if (!context) {
|
||||||
throw new Error("useUserContext must be used within a UserContextProvider");
|
throw new Error(
|
||||||
|
"useUserContext must be used within an UserContextProvider",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return context;
|
return context;
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
import type { SVGProps } from "react";
|
|
||||||
|
|
||||||
export function GoogleIcon(props: SVGProps<SVGSVGElement>) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width={48}
|
|
||||||
height={48}
|
|
||||||
viewBox="0 0 48 48"
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill="#ffc107"
|
|
||||||
d="M43.611 20.083H42V20H24v8h11.303c-1.649 4.657-6.08 8-11.303 8c-6.627 0-12-5.373-12-12s5.373-12 12-12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4C12.955 4 4 12.955 4 24s8.955 20 20 20s20-8.955 20-20c0-1.341-.138-2.65-.389-3.917"
|
|
||||||
></path>
|
|
||||||
<path
|
|
||||||
fill="#ff3d00"
|
|
||||||
d="m6.306 14.691l6.571 4.819C14.655 15.108 18.961 12 24 12c3.059 0 5.842 1.154 7.961 3.039l5.657-5.657C34.046 6.053 29.268 4 24 4C16.318 4 9.656 8.337 6.306 14.691"
|
|
||||||
></path>
|
|
||||||
<path
|
|
||||||
fill="#4caf50"
|
|
||||||
d="M24 44c5.166 0 9.86-1.977 13.409-5.192l-6.19-5.238A11.9 11.9 0 0 1 24 36c-5.202 0-9.619-3.317-11.283-7.946l-6.522 5.025C9.505 39.556 16.227 44 24 44"
|
|
||||||
></path>
|
|
||||||
<path
|
|
||||||
fill="#1976d2"
|
|
||||||
d="M43.611 20.083H42V20H24v8h11.303a12.04 12.04 0 0 1-4.087 5.571l.003-.002l6.19 5.238C36.971 39.205 44 34 44 24c0-1.341-.138-2.65-.389-3.917"
|
|
||||||
></path>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,176 @@
|
|||||||
span,
|
@import "tailwindcss";
|
||||||
p {
|
@import "tw-animate-css";
|
||||||
word-break: break-word;
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--chart-1: oklch(0.646 0.222 41.116);
|
||||||
|
--chart-2: oklch(0.6 0.118 184.704);
|
||||||
|
--chart-3: oklch(0.398 0.07 227.392);
|
||||||
|
--chart-4: oklch(0.828 0.189 84.429);
|
||||||
|
--chart-5: oklch(0.769 0.188 70.08);
|
||||||
|
--sidebar: oklch(0.985 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.145 0 0);
|
||||||
|
--sidebar-primary: oklch(0.205 0 0);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.97 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||||
|
--sidebar-border: oklch(0.922 0 0);
|
||||||
|
--sidebar-ring: oklch(0.708 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.205 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.205 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.922 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.704 0.191 22.216);
|
||||||
|
--border: oklch(1 0 0 / 10%);
|
||||||
|
--input: oklch(1 0 0 / 15%);
|
||||||
|
--ring: oklch(0.556 0 0);
|
||||||
|
--chart-1: oklch(0.488 0.243 264.376);
|
||||||
|
--chart-2: oklch(0.696 0.17 162.48);
|
||||||
|
--chart-3: oklch(0.769 0.188 70.08);
|
||||||
|
--chart-4: oklch(0.627 0.265 303.9);
|
||||||
|
--chart-5: oklch(0.645 0.246 16.439);
|
||||||
|
--sidebar: oklch(0.205 0 0);
|
||||||
|
--sidebar-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||||
|
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-accent: oklch(0.269 0 0);
|
||||||
|
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||||
|
--sidebar-border: oklch(1 0 0 / 10%);
|
||||||
|
--sidebar-ring: oklch(0.556 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border outline-ring/50;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
@apply scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
@apply scroll-m-20 border-b pb-2 text-3xl font-semibold tracking-tight first:mt-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
@apply scroll-m-20 text-2xl font-semibold tracking-tight;
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
@apply scroll-m-20 text-xl font-semibold tracking-tight;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
@apply leading-6 [&:not(:first-child)]:mt-6;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
@apply mt-6 border-l-2 pl-6 italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr {
|
||||||
|
@apply m-0 border-t p-0 even:bg-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
@apply border px-4 py-2 text-left font-bold [&[align=center]]:text-center [&[align=right]]:text-right;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul {
|
||||||
|
@apply my-6 ml-6 list-disc [&>li]:mt-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
@apply relative rounded bg-muted px-[0.2rem] py-[0.1rem] font-mono text-sm font-semibold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
@apply text-xl text-muted-foreground;
|
||||||
|
}
|
||||||
|
|
||||||
|
.large {
|
||||||
|
@apply text-lg font-semibold;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
@apply text-sm font-medium leading-none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
@apply text-sm text-muted-foreground;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,15 @@
|
|||||||
import { useCallback, useEffect, useRef } from 'react'
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
|
||||||
/**
|
|
||||||
* Custom hook that determines if the component is currently mounted.
|
|
||||||
* @returns {() => boolean} A function that returns a boolean value indicating whether the component is mounted.
|
|
||||||
* @public
|
|
||||||
* @see [Documentation](https://usehooks-ts.com/react-hook/use-is-mounted)
|
|
||||||
* @example
|
|
||||||
* ```tsx
|
|
||||||
* const isComponentMounted = useIsMounted();
|
|
||||||
* // Use isComponentMounted() to check if the component is currently mounted before performing certain actions.
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function useIsMounted(): () => boolean {
|
export function useIsMounted(): () => boolean {
|
||||||
const isMounted = useRef(false)
|
const isMounted = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
isMounted.current = true
|
isMounted.current = true;
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
isMounted.current = false
|
isMounted.current = false;
|
||||||
}
|
};
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
return useCallback(() => isMounted.current, [])
|
return useCallback(() => isMounted.current, []);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,16 @@
|
|||||||
import i18n from "i18next";
|
import i18n from "i18next";
|
||||||
import { initReactI18next } from "react-i18next";
|
import { initReactI18next } from "react-i18next";
|
||||||
import LanguageDetector from "i18next-browser-languagedetector";
|
import LanguageDetector from "i18next-browser-languagedetector";
|
||||||
import ChainedBackend from "i18next-chained-backend";
|
|
||||||
import resourcesToBackend from "i18next-resources-to-backend";
|
import resourcesToBackend from "i18next-resources-to-backend";
|
||||||
import HttpBackend from "i18next-http-backend";
|
|
||||||
|
|
||||||
const backends = [
|
|
||||||
HttpBackend,
|
|
||||||
resourcesToBackend(
|
|
||||||
(language: string) => import(`./locales/${language}.json`),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
const backendOptions = [
|
|
||||||
{
|
|
||||||
loadPath: "https://cdn.tinyauth.app/i18n/v1/{{lng}}.json",
|
|
||||||
},
|
|
||||||
{}
|
|
||||||
]
|
|
||||||
|
|
||||||
i18n
|
i18n
|
||||||
.use(ChainedBackend)
|
|
||||||
.use(LanguageDetector)
|
.use(LanguageDetector)
|
||||||
.use(initReactI18next)
|
.use(initReactI18next)
|
||||||
|
.use(
|
||||||
|
resourcesToBackend(
|
||||||
|
(language: string) => import(`./locales/${language}.json`),
|
||||||
|
),
|
||||||
|
)
|
||||||
.init({
|
.init({
|
||||||
fallbackLng: "en",
|
fallbackLng: "en",
|
||||||
debug: import.meta.env.MODE === "development",
|
debug: import.meta.env.MODE === "development",
|
||||||
@@ -32,11 +20,6 @@ i18n
|
|||||||
},
|
},
|
||||||
|
|
||||||
load: "currentOnly",
|
load: "currentOnly",
|
||||||
|
|
||||||
backend: {
|
|
||||||
backends: import.meta.env.MODE !== "development" ? backends : backends.reverse(),
|
|
||||||
backendOptions: import.meta.env.MODE !== "development" ? backendOptions : backendOptions.reverse()
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export default i18n;
|
export default i18n;
|
||||||
|
|||||||
@@ -1,36 +1,37 @@
|
|||||||
export const languages = {
|
export const languages = {
|
||||||
"af-ZA": "Afrikaans",
|
"af-ZA": "Afrikaans",
|
||||||
"ar-SA": "العربية",
|
"ar-SA": "العربية",
|
||||||
"ca-ES": "Català",
|
"ca-ES": "Català",
|
||||||
"cs-CZ": "Čeština",
|
"cs-CZ": "Čeština",
|
||||||
"da-DK": "Dansk",
|
"da-DK": "Dansk",
|
||||||
"de-DE": "Deutsch",
|
"de-DE": "Deutsch",
|
||||||
"el-GR": "Ελληνικά",
|
"el-GR": "Ελληνικά",
|
||||||
"en-US": "English",
|
"en-US": "English",
|
||||||
"es-ES": "Español",
|
"es-ES": "Español",
|
||||||
"fi-FI": "Suomi",
|
"fi-FI": "Suomi",
|
||||||
"fr-FR": "Français",
|
"fr-FR": "Français",
|
||||||
"he-IL": "עברית",
|
"he-IL": "עברית",
|
||||||
"hu-HU": "Magyar",
|
"hu-HU": "Magyar",
|
||||||
"it-IT": "Italiano",
|
"it-IT": "Italiano",
|
||||||
"ja-JP": "日本語",
|
"ja-JP": "日本語",
|
||||||
"ko-KR": "한국어",
|
"ko-KR": "한국어",
|
||||||
"nl-NL": "Nederlands",
|
"nl-NL": "Nederlands",
|
||||||
"no-NO": "Norsk",
|
"no-NO": "Norsk",
|
||||||
"pl-PL": "Polski",
|
"pl-PL": "Polski",
|
||||||
"pt-BR": "Português",
|
"pt-BR": "Português",
|
||||||
"pt-PT": "Português",
|
"pt-PT": "Português",
|
||||||
"ro-RO": "Română",
|
"ro-RO": "Română",
|
||||||
"ru-RU": "Русский",
|
"ru-RU": "Русский",
|
||||||
"sr-SP": "Српски",
|
"sr-SP": "Српски",
|
||||||
"sv-SE": "Svenska",
|
"sv-SE": "Svenska",
|
||||||
"tr-TR": "Türkçe",
|
"tr-TR": "Türkçe",
|
||||||
"uk-UA": "Українська",
|
"uk-UA": "Українська",
|
||||||
"vi-VN": "Tiếng Việt",
|
"vi-VN": "Tiếng Việt",
|
||||||
"zh-CN": "中文",
|
"zh-CN": "中文",
|
||||||
"zh-TW": "中文"
|
"zh-TW": "中文",
|
||||||
}
|
};
|
||||||
|
|
||||||
export type SupportedLanguage = keyof typeof languages;
|
export type SupportedLanguage = keyof typeof languages;
|
||||||
|
|
||||||
export const getLanguageName = (language: SupportedLanguage): string => languages[language];
|
export const getLanguageName = (language: SupportedLanguage): string =>
|
||||||
|
languages[language];
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
{
|
{
|
||||||
"loginTitle": "Welcome back, login with",
|
"loginTitle": "Welcome back, login with",
|
||||||
"loginDivider": "Or continue with password",
|
"loginTitleSimple": "Welcome back, please login",
|
||||||
|
"loginDivider": "Or",
|
||||||
"loginUsername": "Username",
|
"loginUsername": "Username",
|
||||||
"loginPassword": "Password",
|
"loginPassword": "Password",
|
||||||
"loginSubmit": "Login",
|
"loginSubmit": "Login",
|
||||||
"loginFailTitle": "Failed to log in",
|
"loginFailTitle": "Failed to log in",
|
||||||
"loginFailSubtitle": "Please check your username and password",
|
"loginFailSubtitle": "Please check your username and password",
|
||||||
"loginFailRateLimit": "You failed to login too many times, please try again later",
|
"loginFailRateLimit": "You failed to login too many times. Please try again later",
|
||||||
"loginSuccessTitle": "Logged in",
|
"loginSuccessTitle": "Logged in",
|
||||||
"loginSuccessSubtitle": "Welcome back!",
|
"loginSuccessSubtitle": "Welcome back!",
|
||||||
"loginOauthFailTitle": "Internal error",
|
"loginOauthFailTitle": "An error occurred",
|
||||||
"loginOauthFailSubtitle": "Failed to get OAuth URL",
|
"loginOauthFailSubtitle": "Failed to get OAuth URL",
|
||||||
"loginOauthSuccessTitle": "Redirecting",
|
"loginOauthSuccessTitle": "Redirecting",
|
||||||
"loginOauthSuccessSubtitle": "Redirecting to your OAuth provider",
|
"loginOauthSuccessSubtitle": "Redirecting to your OAuth provider",
|
||||||
@@ -18,19 +19,16 @@
|
|||||||
"continueInvalidRedirectTitle": "Invalid redirect",
|
"continueInvalidRedirectTitle": "Invalid redirect",
|
||||||
"continueInvalidRedirectSubtitle": "The redirect URL is invalid",
|
"continueInvalidRedirectSubtitle": "The redirect URL is invalid",
|
||||||
"continueInsecureRedirectTitle": "Insecure redirect",
|
"continueInsecureRedirectTitle": "Insecure redirect",
|
||||||
"continueInsecureRedirectSubtitle": "You are trying to redirect from <Code>https</Code> to <Code>http</Code>, are you sure you want to continue?",
|
"continueInsecureRedirectSubtitle": "You are trying to redirect from <code>https</code> to <code>http</code> which is not secure. Are you sure you want to continue?",
|
||||||
"continueTitle": "Continue",
|
"continueTitle": "Continue",
|
||||||
"continueSubtitle": "Click the button to continue to your app.",
|
"continueSubtitle": "Click the button to continue to your app.",
|
||||||
"internalErrorTitle": "Internal Server Error",
|
|
||||||
"internalErrorSubtitle": "An error occurred on the server and it currently cannot serve your request.",
|
|
||||||
"internalErrorButton": "Try again",
|
|
||||||
"logoutFailTitle": "Failed to log out",
|
"logoutFailTitle": "Failed to log out",
|
||||||
"logoutFailSubtitle": "Please try again",
|
"logoutFailSubtitle": "Please try again",
|
||||||
"logoutSuccessTitle": "Logged out",
|
"logoutSuccessTitle": "Logged out",
|
||||||
"logoutSuccessSubtitle": "You have been logged out",
|
"logoutSuccessSubtitle": "You have been logged out",
|
||||||
"logoutTitle": "Logout",
|
"logoutTitle": "Logout",
|
||||||
"logoutUsernameSubtitle": "You are currently logged in as <Code>{{username}}</Code>, click the button below to logout.",
|
"logoutUsernameSubtitle": "You are currently logged in as <code>{{username}}</code>. Click the button below to logout.",
|
||||||
"logoutOauthSubtitle": "You are currently logged in as <Code>{{username}}</Code> using the {{provider}} OAuth provider, click the button below to logout.",
|
"logoutOauthSubtitle": "You are currently logged in as <code>{{username}}</code> using the {{provider}} OAuth provider. Click the button below to logout.",
|
||||||
"notFoundTitle": "Page not found",
|
"notFoundTitle": "Page not found",
|
||||||
"notFoundSubtitle": "The page you are looking for does not exist.",
|
"notFoundSubtitle": "The page you are looking for does not exist.",
|
||||||
"notFoundButton": "Go home",
|
"notFoundButton": "Go home",
|
||||||
@@ -39,13 +37,17 @@
|
|||||||
"totpSuccessTitle": "Verified",
|
"totpSuccessTitle": "Verified",
|
||||||
"totpSuccessSubtitle": "Redirecting to your app",
|
"totpSuccessSubtitle": "Redirecting to your app",
|
||||||
"totpTitle": "Enter your TOTP code",
|
"totpTitle": "Enter your TOTP code",
|
||||||
|
"totpSubtitle": "Please enter the code from your authenticator app.",
|
||||||
"unauthorizedTitle": "Unauthorized",
|
"unauthorizedTitle": "Unauthorized",
|
||||||
"unauthorizedResourceSubtitle": "The user with username <Code>{{username}}</Code> is not authorized to access the resource <Code>{{resource}}</Code>.",
|
"unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
|
||||||
"unauthorizedLoginSubtitle": "The user with username <Code>{{username}}</Code> is not authorized to login.",
|
"unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.",
|
||||||
"unauthorizedGroupsSubtitle": "The user with username <Code>{{username}}</Code> is not in the groups required by the resource <Code>{{resource}}</Code>.",
|
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.",
|
||||||
"unauthorizedButton": "Try again",
|
"unauthorizedButton": "Try again",
|
||||||
"untrustedRedirectTitle": "Untrusted redirect",
|
"untrustedRedirectTitle": "Untrusted redirect",
|
||||||
"untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<Code>{{domain}}</Code>). Are you sure you want to continue?",
|
"untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?",
|
||||||
"cancelTitle": "Cancel",
|
"cancelTitle": "Cancel",
|
||||||
"forgotPasswordTitle": "Forgot your password?"
|
"forgotPasswordTitle": "Forgot your password?",
|
||||||
|
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.",
|
||||||
|
"errorTitle": "An error occurred",
|
||||||
|
"errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information."
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
{
|
{
|
||||||
"loginTitle": "Welcome back, login with",
|
"loginTitle": "Welcome back, login with",
|
||||||
"loginDivider": "Or continue with password",
|
"loginTitleSimple": "Welcome back, please login",
|
||||||
|
"loginDivider": "Or",
|
||||||
"loginUsername": "Username",
|
"loginUsername": "Username",
|
||||||
"loginPassword": "Password",
|
"loginPassword": "Password",
|
||||||
"loginSubmit": "Login",
|
"loginSubmit": "Login",
|
||||||
"loginFailTitle": "Failed to log in",
|
"loginFailTitle": "Failed to log in",
|
||||||
"loginFailSubtitle": "Please check your username and password",
|
"loginFailSubtitle": "Please check your username and password",
|
||||||
"loginFailRateLimit": "You failed to login too many times, please try again later",
|
"loginFailRateLimit": "You failed to login too many times. Please try again later",
|
||||||
"loginSuccessTitle": "Logged in",
|
"loginSuccessTitle": "Logged in",
|
||||||
"loginSuccessSubtitle": "Welcome back!",
|
"loginSuccessSubtitle": "Welcome back!",
|
||||||
"loginOauthFailTitle": "Internal error",
|
"loginOauthFailTitle": "An error occurred",
|
||||||
"loginOauthFailSubtitle": "Failed to get OAuth URL",
|
"loginOauthFailSubtitle": "Failed to get OAuth URL",
|
||||||
"loginOauthSuccessTitle": "Redirecting",
|
"loginOauthSuccessTitle": "Redirecting",
|
||||||
"loginOauthSuccessSubtitle": "Redirecting to your OAuth provider",
|
"loginOauthSuccessSubtitle": "Redirecting to your OAuth provider",
|
||||||
@@ -18,19 +19,16 @@
|
|||||||
"continueInvalidRedirectTitle": "Invalid redirect",
|
"continueInvalidRedirectTitle": "Invalid redirect",
|
||||||
"continueInvalidRedirectSubtitle": "The redirect URL is invalid",
|
"continueInvalidRedirectSubtitle": "The redirect URL is invalid",
|
||||||
"continueInsecureRedirectTitle": "Insecure redirect",
|
"continueInsecureRedirectTitle": "Insecure redirect",
|
||||||
"continueInsecureRedirectSubtitle": "You are trying to redirect from <Code>https</Code> to <Code>http</Code>, are you sure you want to continue?",
|
"continueInsecureRedirectSubtitle": "You are trying to redirect from <code>https</code> to <code>http</code> which is not secure. Are you sure you want to continue?",
|
||||||
"continueTitle": "Continue",
|
"continueTitle": "Continue",
|
||||||
"continueSubtitle": "Click the button to continue to your app.",
|
"continueSubtitle": "Click the button to continue to your app.",
|
||||||
"internalErrorTitle": "Internal Server Error",
|
|
||||||
"internalErrorSubtitle": "An error occurred on the server and it currently cannot serve your request.",
|
|
||||||
"internalErrorButton": "Try again",
|
|
||||||
"logoutFailTitle": "Failed to log out",
|
"logoutFailTitle": "Failed to log out",
|
||||||
"logoutFailSubtitle": "Please try again",
|
"logoutFailSubtitle": "Please try again",
|
||||||
"logoutSuccessTitle": "Logged out",
|
"logoutSuccessTitle": "Logged out",
|
||||||
"logoutSuccessSubtitle": "You have been logged out",
|
"logoutSuccessSubtitle": "You have been logged out",
|
||||||
"logoutTitle": "Logout",
|
"logoutTitle": "Logout",
|
||||||
"logoutUsernameSubtitle": "You are currently logged in as <Code>{{username}}</Code>, click the button below to logout.",
|
"logoutUsernameSubtitle": "You are currently logged in as <code>{{username}}</code>. Click the button below to logout.",
|
||||||
"logoutOauthSubtitle": "You are currently logged in as <Code>{{username}}</Code> using the {{provider}} OAuth provider, click the button below to logout.",
|
"logoutOauthSubtitle": "You are currently logged in as <code>{{username}}</code> using the {{provider}} OAuth provider. Click the button below to logout.",
|
||||||
"notFoundTitle": "Page not found",
|
"notFoundTitle": "Page not found",
|
||||||
"notFoundSubtitle": "The page you are looking for does not exist.",
|
"notFoundSubtitle": "The page you are looking for does not exist.",
|
||||||
"notFoundButton": "Go home",
|
"notFoundButton": "Go home",
|
||||||
@@ -39,13 +37,17 @@
|
|||||||
"totpSuccessTitle": "Verified",
|
"totpSuccessTitle": "Verified",
|
||||||
"totpSuccessSubtitle": "Redirecting to your app",
|
"totpSuccessSubtitle": "Redirecting to your app",
|
||||||
"totpTitle": "Enter your TOTP code",
|
"totpTitle": "Enter your TOTP code",
|
||||||
|
"totpSubtitle": "Please enter the code from your authenticator app.",
|
||||||
"unauthorizedTitle": "Unauthorized",
|
"unauthorizedTitle": "Unauthorized",
|
||||||
"unauthorizedResourceSubtitle": "The user with username <Code>{{username}}</Code> is not authorized to access the resource <Code>{{resource}}</Code>.",
|
"unauthorizedResourceSubtitle": "The user with username <code>{{username}}</code> is not authorized to access the resource <code>{{resource}}</code>.",
|
||||||
"unauthorizedLoginSubtitle": "The user with username <Code>{{username}}</Code> is not authorized to login.",
|
"unauthorizedLoginSubtitle": "The user with username <code>{{username}}</code> is not authorized to login.",
|
||||||
"unauthorizedGroupsSubtitle": "The user with username <Code>{{username}}</Code> is not in the groups required by the resource <Code>{{resource}}</Code>.",
|
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.",
|
||||||
"unauthorizedButton": "Try again",
|
"unauthorizedButton": "Try again",
|
||||||
"untrustedRedirectTitle": "Untrusted redirect",
|
"untrustedRedirectTitle": "Untrusted redirect",
|
||||||
"untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<Code>{{domain}}</Code>). Are you sure you want to continue?",
|
"untrustedRedirectSubtitle": "You are trying to redirect to a domain that does not match your configured domain (<code>{{domain}}</code>). Are you sure you want to continue?",
|
||||||
"cancelTitle": "Cancel",
|
"cancelTitle": "Cancel",
|
||||||
"forgotPasswordTitle": "Forgot your password?"
|
"forgotPasswordTitle": "Forgot your password?",
|
||||||
|
"failedToFetchProvidersTitle": "Failed to load authentication providers. Please check your configuration.",
|
||||||
|
"errorTitle": "An error occurred",
|
||||||
|
"errorSubtitle": "An error occurred while trying to perform this action. Please check the console for more information."
|
||||||
}
|
}
|
||||||
19
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isValidUrl = (url: string) => {
|
||||||
|
try {
|
||||||
|
new URL(url);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const capitalize = (str: string) => {
|
||||||
|
return str.charAt(0).toUpperCase() + str.slice(1);
|
||||||
|
};
|
||||||
@@ -1,54 +1,50 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import { App } from "./App.tsx";
|
|
||||||
import { MantineProvider } from "@mantine/core";
|
|
||||||
import { Notifications } from "@mantine/notifications";
|
|
||||||
import "@mantine/core/styles.css";
|
|
||||||
import "@mantine/notifications/styles.css";
|
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
import { BrowserRouter, Route } from "react-router";
|
|
||||||
import { Routes } from "react-router";
|
|
||||||
import { UserContextProvider } from "./context/user-context.tsx";
|
|
||||||
import { LoginPage } from "./pages/login-page.tsx";
|
|
||||||
import { LogoutPage } from "./pages/logout-page.tsx";
|
|
||||||
import { ContinuePage } from "./pages/continue-page.tsx";
|
|
||||||
import { NotFoundPage } from "./pages/not-found-page.tsx";
|
|
||||||
import { UnauthorizedPage } from "./pages/unauthorized-page.tsx";
|
|
||||||
import { InternalServerError } from "./pages/internal-server-error.tsx";
|
|
||||||
import { TotpPage } from "./pages/totp-page.tsx";
|
|
||||||
import { AppContextProvider } from "./context/app-context.tsx";
|
|
||||||
import "./lib/i18n/i18n.ts";
|
|
||||||
import { ForgotPasswordPage } from "./pages/forgot-password-page.tsx";
|
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
import { Layout } from "./components/layout/layout.tsx";
|
||||||
|
import { BrowserRouter, Route, Routes } from "react-router";
|
||||||
|
import { LoginPage } from "./pages/login-page.tsx";
|
||||||
|
import { App } from "./App.tsx";
|
||||||
|
import { ErrorPage } from "./pages/error-page.tsx";
|
||||||
|
import { NotFoundPage } from "./pages/not-found-page.tsx";
|
||||||
|
import { ContinuePage } from "./pages/continue-page.tsx";
|
||||||
|
import { TotpPage } from "./pages/totp-page.tsx";
|
||||||
|
import { ForgotPasswordPage } from "./pages/forgot-password-page.tsx";
|
||||||
|
import { LogoutPage } from "./pages/logout-page.tsx";
|
||||||
|
import { UnauthorizedPage } from "./pages/unauthorized-page.tsx";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { AppContextProvider } from "./context/app-context.tsx";
|
||||||
|
import { UserContextProvider } from "./context/user-context.tsx";
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<MantineProvider defaultColorScheme="auto">
|
<QueryClientProvider client={queryClient}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<AppContextProvider>
|
||||||
<Notifications />
|
<UserContextProvider>
|
||||||
<AppContextProvider>
|
<BrowserRouter>
|
||||||
<UserContextProvider>
|
<Routes>
|
||||||
<BrowserRouter>
|
<Route element={<Layout />} errorElement={<ErrorPage />}>
|
||||||
<Routes>
|
|
||||||
<Route path="/" element={<App />} />
|
<Route path="/" element={<App />} />
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/totp" element={<TotpPage />} />
|
|
||||||
<Route path="/logout" element={<LogoutPage />} />
|
<Route path="/logout" element={<LogoutPage />} />
|
||||||
<Route path="/continue" element={<ContinuePage />} />
|
<Route path="/continue" element={<ContinuePage />} />
|
||||||
<Route path="/unauthorized" element={<UnauthorizedPage />} />
|
<Route path="/totp" element={<TotpPage />} />
|
||||||
<Route path="/error" element={<InternalServerError />} />
|
|
||||||
<Route
|
<Route
|
||||||
path="/forgot-password"
|
path="/forgot-password"
|
||||||
element={<ForgotPasswordPage />}
|
element={<ForgotPasswordPage />}
|
||||||
/>
|
/>
|
||||||
|
<Route path="/unauthorized" element={<UnauthorizedPage />} />
|
||||||
|
<Route path="/error" element={<ErrorPage />} />
|
||||||
<Route path="*" element={<NotFoundPage />} />
|
<Route path="*" element={<NotFoundPage />} />
|
||||||
</Routes>
|
</Route>
|
||||||
</BrowserRouter>
|
</Routes>
|
||||||
</UserContextProvider>
|
</BrowserRouter>
|
||||||
</AppContextProvider>
|
<Toaster />
|
||||||
</QueryClientProvider>
|
</UserContextProvider>
|
||||||
</MantineProvider>
|
</AppContextProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,144 +1,136 @@
|
|||||||
import { Button, Code, Paper, Text } from "@mantine/core";
|
import { Button } from "@/components/ui/button";
|
||||||
import { notifications } from "@mantine/notifications";
|
import {
|
||||||
import { Navigate } from "react-router";
|
Card,
|
||||||
import { useUserContext } from "../context/user-context";
|
CardDescription,
|
||||||
import { Layout } from "../components/layouts/layout";
|
CardFooter,
|
||||||
import { ReactNode } from "react";
|
CardHeader,
|
||||||
import { escapeRegex, isValidRedirectUri } from "../utils/utils";
|
CardTitle,
|
||||||
import { useAppContext } from "../context/app-context";
|
} from "@/components/ui/card";
|
||||||
|
import { useAppContext } from "@/context/app-context";
|
||||||
|
import { useUserContext } from "@/context/user-context";
|
||||||
|
import { isValidUrl } from "@/lib/utils";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
import { Navigate, useLocation, useNavigate } from "react-router";
|
||||||
|
import DOMPurify from "dompurify";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
export const ContinuePage = () => {
|
export const ContinuePage = () => {
|
||||||
const queryString = window.location.search;
|
|
||||||
const params = new URLSearchParams(queryString);
|
|
||||||
const redirectUri = params.get("redirect_uri") ?? "";
|
|
||||||
|
|
||||||
const { isLoggedIn } = useUserContext();
|
const { isLoggedIn } = useUserContext();
|
||||||
const { disableContinue, domain } = useAppContext();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
return <Navigate to={`/login?redirect_uri=${redirectUri}`} />;
|
return <Navigate to="/login" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isValidRedirectUri(redirectUri)) {
|
const { domain, disableContinue } = useAppContext();
|
||||||
return <Navigate to="/" />;
|
const { search } = useLocation();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams(search);
|
||||||
|
const redirectURI = searchParams.get("redirect_uri");
|
||||||
|
|
||||||
|
if (!redirectURI) {
|
||||||
|
return <Navigate to="/logout" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const redirect = () => {
|
if (!isValidUrl(DOMPurify.sanitize(redirectURI))) {
|
||||||
notifications.show({
|
return <Navigate to="/logout" />;
|
||||||
title: t("continueRedirectingTitle"),
|
|
||||||
message: t("continueRedirectingSubtitle"),
|
|
||||||
color: "blue",
|
|
||||||
});
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.href = redirectUri;
|
|
||||||
}, 500);
|
|
||||||
};
|
|
||||||
|
|
||||||
let uri;
|
|
||||||
|
|
||||||
try {
|
|
||||||
uri = new URL(redirectUri);
|
|
||||||
} catch {
|
|
||||||
return (
|
|
||||||
<ContinuePageLayout>
|
|
||||||
<Text size="xl" fw={700}>
|
|
||||||
{t("Invalid redirect")}
|
|
||||||
</Text>
|
|
||||||
<Text>{t("The redirect URL is invalid")}</Text>
|
|
||||||
</ContinuePageLayout>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const regex = new RegExp(`^.*${escapeRegex(domain)}$`);
|
const handleRedirect = () => {
|
||||||
|
setLoading(true);
|
||||||
if (!regex.test(uri.hostname)) {
|
window.location.href = DOMPurify.sanitize(redirectURI);
|
||||||
return (
|
|
||||||
<ContinuePageLayout>
|
|
||||||
<Text size="xl" fw={700}>
|
|
||||||
{t("untrustedRedirectTitle")}
|
|
||||||
</Text>
|
|
||||||
<Trans
|
|
||||||
i18nKey="untrustedRedirectSubtitle"
|
|
||||||
t={t}
|
|
||||||
components={{ Code: <Code /> }}
|
|
||||||
values={{ domain: domain }}
|
|
||||||
/>
|
|
||||||
<Button fullWidth mt="xl" color="red" onClick={redirect}>
|
|
||||||
{t("continueTitle")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
fullWidth
|
|
||||||
mt="xs"
|
|
||||||
color="gray"
|
|
||||||
onClick={() => (window.location.href = "/")}
|
|
||||||
>
|
|
||||||
{t("cancelTitle")}
|
|
||||||
</Button>
|
|
||||||
</ContinuePageLayout>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (disableContinue) {
|
if (disableContinue) {
|
||||||
window.location.href = redirectUri;
|
handleRedirect();
|
||||||
|
}
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const url = new URL(redirectURI);
|
||||||
|
|
||||||
|
if (!(url.hostname == domain) && !url.hostname.endsWith(`.${domain}`)) {
|
||||||
return (
|
return (
|
||||||
<ContinuePageLayout>
|
<Card className="min-w-xs sm:min-w-sm">
|
||||||
<Text size="xl" fw={700}>
|
<CardHeader>
|
||||||
{t("continueRedirectingTitle")}
|
<CardTitle className="text-3xl">
|
||||||
</Text>
|
{t("untrustedRedirectTitle")}
|
||||||
<Text>{t("continueRedirectingSubtitle")}</Text>
|
</CardTitle>
|
||||||
</ContinuePageLayout>
|
<CardDescription>
|
||||||
|
<Trans
|
||||||
|
i18nKey="untrustedRedirectSubtitle"
|
||||||
|
t={t}
|
||||||
|
components={{
|
||||||
|
code: <code />,
|
||||||
|
}}
|
||||||
|
values={{ domain }}
|
||||||
|
/>
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardFooter className="flex flex-col items-stretch gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleRedirect}
|
||||||
|
loading={loading}
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
{t("continueTitle")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => navigate("/logout")} variant="outline" disabled={loading}>
|
||||||
|
{t("cancelTitle")}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.location.protocol === "https:" && uri.protocol === "http:") {
|
if (url.protocol === "http:" && window.location.protocol === "https:") {
|
||||||
return (
|
return (
|
||||||
<ContinuePageLayout>
|
<Card className="min-w-xs sm:min-w-sm">
|
||||||
<Text size="xl" fw={700}>
|
<CardHeader>
|
||||||
{t("continueInsecureRedirectTitle")}
|
<CardTitle className="text-3xl">
|
||||||
</Text>
|
{t("continueInsecureRedirectTitle")}
|
||||||
<Text>
|
</CardTitle>
|
||||||
<Trans
|
<CardDescription>
|
||||||
i18nKey="continueInsecureRedirectSubtitle"
|
<Trans
|
||||||
t={t}
|
i18nKey="continueInsecureRedirectSubtitle"
|
||||||
components={{ Code: <Code /> }}
|
t={t}
|
||||||
/>
|
components={{
|
||||||
</Text>
|
code: <code />,
|
||||||
<Button fullWidth mt="xl" color="yellow" onClick={redirect}>
|
}}
|
||||||
|
/>
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardFooter className="flex flex-col items-stretch gap-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleRedirect}
|
||||||
|
loading={loading}
|
||||||
|
variant="warning"
|
||||||
|
>
|
||||||
|
{t("continueTitle")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => navigate("/logout")} variant="outline" disabled={loading}>
|
||||||
|
{t("cancelTitle")}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="min-w-xs sm:min-w-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-3xl">{t("continueTitle")}</CardTitle>
|
||||||
|
<CardDescription>{t("continueSubtitle")}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardFooter className="flex flex-col items-stretch">
|
||||||
|
<Button
|
||||||
|
onClick={handleRedirect}
|
||||||
|
loading={loading}
|
||||||
|
>
|
||||||
{t("continueTitle")}
|
{t("continueTitle")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
</CardFooter>
|
||||||
fullWidth
|
</Card>
|
||||||
mt="xs"
|
|
||||||
color="gray"
|
|
||||||
onClick={() => (window.location.href = "/")}
|
|
||||||
>
|
|
||||||
{t("cancelTitle")}
|
|
||||||
</Button>
|
|
||||||
</ContinuePageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ContinuePageLayout>
|
|
||||||
<Text size="xl" fw={700}>
|
|
||||||
{t("continueTitle")}
|
|
||||||
</Text>
|
|
||||||
<Text>{t("continueSubtitle")}</Text>
|
|
||||||
<Button fullWidth mt="xl" onClick={redirect}>
|
|
||||||
{t("continueTitle")}
|
|
||||||
</Button>
|
|
||||||
</ContinuePageLayout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ContinuePageLayout = ({ children }: { children: ReactNode }) => {
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<Paper shadow="md" p={30} mt={30} radius="md" withBorder>
|
|
||||||
{children}
|
|
||||||
</Paper>
|
|
||||||
</Layout>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
20
frontend/src/pages/error-page.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export const ErrorPage = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="min-w-xs sm:min-w-sm">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-3xl">{t("errorTitle")}</CardTitle>
|
||||||
|
<CardDescription>{t("errorSubtitle")}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,25 +1,25 @@
|
|||||||
import { Paper, Text, TypographyStylesProvider } from "@mantine/core";
|
import {
|
||||||
import { Layout } from "../components/layouts/layout";
|
Card,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { useAppContext } from "@/context/app-context";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAppContext } from "../context/app-context";
|
import Markdown from "react-markdown";
|
||||||
import Markdown from 'react-markdown'
|
|
||||||
|
|
||||||
export const ForgotPasswordPage = () => {
|
export const ForgotPasswordPage = () => {
|
||||||
const { t } = useTranslation();
|
|
||||||
const { forgotPasswordMessage } = useAppContext();
|
const { forgotPasswordMessage } = useAppContext();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Card className="min-w-xs sm:min-w-sm">
|
||||||
<Paper shadow="md" p={30} mt={30} radius="md" withBorder>
|
<CardHeader>
|
||||||
<Text size="xl" fw={700}>
|
<CardTitle className="text-3xl">{t("forgotPasswordTitle")}</CardTitle>
|
||||||
{t("forgotPasswordTitle")}
|
<CardDescription>
|
||||||
</Text>
|
<Markdown>{forgotPasswordMessage}</Markdown>
|
||||||
<TypographyStylesProvider>
|
</CardDescription>
|
||||||
<Markdown>
|
</CardHeader>
|
||||||
{forgotPasswordMessage}
|
</Card>
|
||||||
</Markdown>
|
|
||||||
</TypographyStylesProvider>
|
|
||||||
</Paper>
|
|
||||||
</Layout>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
import { Button, Paper, Text } from "@mantine/core";
|
|
||||||
import { Layout } from "../components/layouts/layout";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
export const InternalServerError = () => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<Paper shadow="md" p={30} mt={30} radius="md" withBorder>
|
|
||||||
<Text size="xl" fw={700}>
|
|
||||||
{t("internalErrorTitle")}
|
|
||||||
</Text>
|
|
||||||
<Text>{t("internalErrorSubtitle")}</Text>
|
|
||||||
<Button fullWidth mt="xl" onClick={() => window.location.replace("/")}>
|
|
||||||
{t("internalErrorButton")}
|
|
||||||
</Button>
|
|
||||||
</Paper>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,181 +1,172 @@
|
|||||||
import { Paper, Title, Text, Divider } from "@mantine/core";
|
import { LoginForm } from "@/components/auth/login-form";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { GenericIcon } from "@/components/icons/generic";
|
||||||
|
import { GithubIcon } from "@/components/icons/github";
|
||||||
|
import { GoogleIcon } from "@/components/icons/google";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
CardContent,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { OAuthButton } from "@/components/ui/oauth-button";
|
||||||
|
import { SeperatorWithChildren } from "@/components/ui/separator";
|
||||||
|
import { useAppContext } from "@/context/app-context";
|
||||||
|
import { useUserContext } from "@/context/user-context";
|
||||||
|
import { useIsMounted } from "@/lib/hooks/use-is-mounted";
|
||||||
|
import { LoginSchema } from "@/schemas/login-schema";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import axios, { type AxiosError } from "axios";
|
import axios, { AxiosError } from "axios";
|
||||||
import { useUserContext } from "../context/user-context";
|
import { useEffect } from "react";
|
||||||
import { Navigate } from "react-router";
|
|
||||||
import { Layout } from "../components/layouts/layout";
|
|
||||||
import { OAuthButtons } from "../components/auth/oauth-buttons";
|
|
||||||
import { LoginFormValues } from "../schemas/login-schema";
|
|
||||||
import { LoginForm } from "../components/auth/login-forn";
|
|
||||||
import { useAppContext } from "../context/app-context";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEffect, useState } from "react";
|
import { Navigate, useLocation } from "react-router";
|
||||||
import { useIsMounted } from "../lib/hooks/use-is-mounted";
|
import { toast } from "sonner";
|
||||||
import { isValidRedirectUri } from "../utils/utils";
|
|
||||||
|
|
||||||
export const LoginPage = () => {
|
export const LoginPage = () => {
|
||||||
const queryString = window.location.search;
|
|
||||||
const params = new URLSearchParams(queryString);
|
|
||||||
const redirectUri = params.get("redirect_uri") ?? "";
|
|
||||||
|
|
||||||
const { isLoggedIn } = useUserContext();
|
const { isLoggedIn } = useUserContext();
|
||||||
|
|
||||||
if (isLoggedIn) {
|
if (isLoggedIn) {
|
||||||
return <Navigate to="/logout" />;
|
return <Navigate to="/logout" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const { configuredProviders, title, oauthAutoRedirect, genericName } = useAppContext();
|
||||||
configuredProviders,
|
const { search } = useLocation();
|
||||||
title,
|
|
||||||
genericName,
|
|
||||||
oauthAutoRedirect: oauthAutoRedirectContext,
|
|
||||||
} = useAppContext();
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [oauthAutoRedirect, setOAuthAutoRedirect] = useState(
|
|
||||||
oauthAutoRedirectContext,
|
|
||||||
);
|
|
||||||
|
|
||||||
const oauthProviders = configuredProviders.filter(
|
|
||||||
(value) => value !== "username",
|
|
||||||
);
|
|
||||||
|
|
||||||
const isMounted = useIsMounted();
|
const isMounted = useIsMounted();
|
||||||
|
|
||||||
const loginMutation = useMutation({
|
const searchParams = new URLSearchParams(search);
|
||||||
mutationFn: (login: LoginFormValues) => {
|
const redirectUri = searchParams.get("redirect_uri");
|
||||||
return axios.post("/api/login", login);
|
|
||||||
},
|
|
||||||
onError: (data: AxiosError) => {
|
|
||||||
if (data.response) {
|
|
||||||
if (data.response.status === 429) {
|
|
||||||
notifications.show({
|
|
||||||
title: t("loginFailTitle"),
|
|
||||||
message: t("loginFailRateLimit"),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
notifications.show({
|
|
||||||
title: t("loginFailTitle"),
|
|
||||||
message: t("loginFailSubtitle"),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSuccess: async (data) => {
|
|
||||||
if (data.data.totpPending) {
|
|
||||||
window.location.replace(`/totp?redirect_uri=${redirectUri}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
notifications.show({
|
const oauthConfigured =
|
||||||
title: t("loginSuccessTitle"),
|
configuredProviders.filter((provider) => provider !== "username").length >
|
||||||
message: t("loginSuccessSubtitle"),
|
0;
|
||||||
color: "green",
|
const userAuthConfigured = configuredProviders.includes("username");
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
const oauthMutation = useMutation({
|
||||||
if (!isValidRedirectUri(redirectUri)) {
|
mutationFn: (provider: string) =>
|
||||||
window.location.replace("/");
|
axios.get(
|
||||||
return;
|
`/api/oauth/url/${provider}?redirect_uri=${encodeURIComponent(redirectUri ?? "")}`,
|
||||||
}
|
),
|
||||||
|
mutationKey: ["oauth"],
|
||||||
window.location.replace(`/continue?redirect_uri=${redirectUri}`);
|
|
||||||
}, 500);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const loginOAuthMutation = useMutation({
|
|
||||||
mutationFn: (provider: string) => {
|
|
||||||
return axios.get(
|
|
||||||
`/api/oauth/url/${provider}?redirect_uri=${redirectUri}`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
notifications.show({
|
|
||||||
title: t("loginOauthFailTitle"),
|
|
||||||
message: t("loginOauthFailSubtitle"),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
setOAuthAutoRedirect("none");
|
|
||||||
},
|
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
notifications.show({
|
toast.info(t("loginOauthSuccessTitle"), {
|
||||||
title: t("loginOauthSuccessTitle"),
|
description: t("loginOauthSuccessSubtitle"),
|
||||||
message: t("loginOauthSuccessSubtitle"),
|
|
||||||
color: "blue",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
window.location.href = data.data.url;
|
window.location.href = data.data.url;
|
||||||
}, 500);
|
}, 500);
|
||||||
},
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("loginOauthFailTitle"), {
|
||||||
|
description: t("loginOauthFailSubtitle"),
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = (values: LoginFormValues) => {
|
const loginMutation = useMutation({
|
||||||
loginMutation.mutate(values);
|
mutationFn: (values: LoginSchema) => axios.post("/api/login", values),
|
||||||
};
|
mutationKey: ["login"],
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data.data.totpPending) {
|
||||||
|
window.location.replace(
|
||||||
|
`/totp?redirect_uri=${encodeURIComponent(redirectUri ?? "")}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.success(t("loginSuccessTitle"), {
|
||||||
|
description: t("loginSuccessSubtitle"),
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.replace(
|
||||||
|
`/continue?redirect_uri=${encodeURIComponent(redirectUri ?? "")}`,
|
||||||
|
);
|
||||||
|
}, 500);
|
||||||
|
},
|
||||||
|
onError: (error: AxiosError) => {
|
||||||
|
toast.error(t("loginFailTitle"), {
|
||||||
|
description:
|
||||||
|
error.response?.status === 429
|
||||||
|
? t("loginFailRateLimit")
|
||||||
|
: t("loginFailSubtitle"),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isMounted()) {
|
if (isMounted()) {
|
||||||
if (
|
if (
|
||||||
oauthProviders.includes(oauthAutoRedirect) &&
|
oauthConfigured &&
|
||||||
isValidRedirectUri(redirectUri)
|
configuredProviders.includes(oauthAutoRedirect) &&
|
||||||
|
redirectUri
|
||||||
) {
|
) {
|
||||||
loginOAuthMutation.mutate(oauthAutoRedirect);
|
oauthMutation.mutate(oauthAutoRedirect);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (
|
|
||||||
oauthProviders.includes(oauthAutoRedirect) &&
|
|
||||||
isValidRedirectUri(redirectUri)
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<Layout>
|
|
||||||
<Paper shadow="md" p="xl" mt={30} radius="md" withBorder>
|
|
||||||
<Text size="xl" fw={700}>
|
|
||||||
{t("continueRedirectingTitle")}
|
|
||||||
</Text>
|
|
||||||
<Text>{t("loginOauthSuccessSubtitle")}</Text>
|
|
||||||
</Paper>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Card className="min-w-xs sm:min-w-sm">
|
||||||
<Title ta="center">{title}</Title>
|
<CardHeader>
|
||||||
<Paper shadow="md" p="xl" mt={30} radius="md" withBorder>
|
<CardTitle className="text-center text-3xl">{title}</CardTitle>
|
||||||
{oauthProviders.length > 0 && (
|
{configuredProviders.length > 0 && (
|
||||||
<>
|
<CardDescription className="text-center">
|
||||||
<Text size="lg" fw={500} ta="center">
|
{oauthConfigured ? t("loginTitle") : t("loginTitleSimple")}
|
||||||
{t("loginTitle")}
|
</CardDescription>
|
||||||
</Text>
|
)}
|
||||||
<OAuthButtons
|
</CardHeader>
|
||||||
oauthProviders={oauthProviders}
|
<CardContent className="flex flex-col gap-4">
|
||||||
isPending={loginOAuthMutation.isPending}
|
{oauthConfigured && (
|
||||||
mutate={loginOAuthMutation.mutate}
|
<div className="flex flex-col gap-2 items-center justify-center">
|
||||||
genericName={genericName}
|
{configuredProviders.includes("google") && (
|
||||||
/>
|
<OAuthButton
|
||||||
{configuredProviders.includes("username") && (
|
title="Google"
|
||||||
<Divider
|
icon={<GoogleIcon />}
|
||||||
label={t("loginDivider")}
|
className="w-full"
|
||||||
labelPosition="center"
|
onClick={() => oauthMutation.mutate("google")}
|
||||||
my="lg"
|
loading={oauthMutation.isPending && oauthMutation.variables === "google"}
|
||||||
|
disabled={oauthMutation.isPending || loginMutation.isPending}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
{configuredProviders.includes("github") && (
|
||||||
|
<OAuthButton
|
||||||
|
title="Github"
|
||||||
|
icon={<GithubIcon />}
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => oauthMutation.mutate("github")}
|
||||||
|
loading={oauthMutation.isPending && oauthMutation.variables === "github"}
|
||||||
|
disabled={oauthMutation.isPending || loginMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{configuredProviders.includes("generic") && (
|
||||||
|
<OAuthButton
|
||||||
|
title={genericName}
|
||||||
|
icon={<GenericIcon />}
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => oauthMutation.mutate("generic")}
|
||||||
|
loading={oauthMutation.isPending && oauthMutation.variables === "generic"}
|
||||||
|
disabled={oauthMutation.isPending || loginMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{configuredProviders.includes("username") && (
|
{userAuthConfigured && oauthConfigured && (
|
||||||
|
<SeperatorWithChildren>{t("loginDivider")}</SeperatorWithChildren>
|
||||||
|
)}
|
||||||
|
{userAuthConfigured && (
|
||||||
<LoginForm
|
<LoginForm
|
||||||
isPending={loginMutation.isPending}
|
onSubmit={(values) => loginMutation.mutate(values)}
|
||||||
onSubmit={handleSubmit}
|
loading={loginMutation.isPending || oauthMutation.isPending}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Paper>
|
{configuredProviders.length == 0 && (
|
||||||
</Layout>
|
<h3 className="text-center text-xl text-red-600">
|
||||||
|
{t("failedToFetchProvidersTitle")}
|
||||||
|
</h3>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,84 +1,89 @@
|
|||||||
import { Button, Code, Paper, Text } from "@mantine/core";
|
import { Button } from "@/components/ui/button";
|
||||||
import { notifications } from "@mantine/notifications";
|
import {
|
||||||
|
Card,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { useAppContext } from "@/context/app-context";
|
||||||
|
import { useUserContext } from "@/context/user-context";
|
||||||
|
import { capitalize } from "@/lib/utils";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { useUserContext } from "../context/user-context";
|
|
||||||
import { Navigate } from "react-router";
|
|
||||||
import { Layout } from "../components/layouts/layout";
|
|
||||||
import { capitalize } from "../utils/utils";
|
|
||||||
import { useAppContext } from "../context/app-context";
|
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
|
import { Navigate } from "react-router";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export const LogoutPage = () => {
|
export const LogoutPage = () => {
|
||||||
const { isLoggedIn, oauth, provider, email, username } = useUserContext();
|
const { provider, username, isLoggedIn, email } = useUserContext();
|
||||||
const { genericName } = useAppContext();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
if (!isLoggedIn) {
|
if (!isLoggedIn) {
|
||||||
return <Navigate to="/login" />;
|
return <Navigate to="/login" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { genericName } = useAppContext();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const logoutMutation = useMutation({
|
const logoutMutation = useMutation({
|
||||||
mutationFn: () => {
|
mutationFn: () => axios.post("/api/logout"),
|
||||||
return axios.post("/api/logout");
|
mutationKey: ["logout"],
|
||||||
},
|
|
||||||
onError: () => {
|
|
||||||
notifications.show({
|
|
||||||
title: t("logoutFailTitle"),
|
|
||||||
message: t("logoutFailSubtitle"),
|
|
||||||
color: "red",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
notifications.show({
|
toast.success(t("logoutSuccessTitle"), {
|
||||||
title: t("logoutSuccessTitle"),
|
description: t("logoutSuccessSubtitle"),
|
||||||
message: t("logoutSuccessSubtitle"),
|
|
||||||
color: "green",
|
|
||||||
});
|
});
|
||||||
setTimeout(() => {
|
|
||||||
|
setTimeout(async () => {
|
||||||
window.location.replace("/login");
|
window.location.replace("/login");
|
||||||
}, 500);
|
}, 500);
|
||||||
},
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t("logoutFailTitle"), {
|
||||||
|
description: t("logoutFailSubtitle"),
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Card className="min-w-xs sm:min-w-sm">
|
||||||
<Paper shadow="md" p={30} mt={30} radius="md" withBorder>
|
<CardHeader>
|
||||||
<Text size="xl" fw={700}>
|
<CardTitle className="text-3xl">{t("logoutTitle")}</CardTitle>
|
||||||
{t("logoutTitle")}
|
<CardDescription>
|
||||||
</Text>
|
{provider !== "username" ? (
|
||||||
<Text>
|
|
||||||
{oauth ? (
|
|
||||||
<Trans
|
<Trans
|
||||||
i18nKey="logoutOauthSubtitle"
|
i18nKey="logoutOauthSubtitle"
|
||||||
t={t}
|
t={t}
|
||||||
components={{ Code: <Code /> }}
|
components={{
|
||||||
|
code: <code />,
|
||||||
|
}}
|
||||||
values={{
|
values={{
|
||||||
|
username: email,
|
||||||
provider:
|
provider:
|
||||||
provider === "generic" ? genericName : capitalize(provider),
|
provider === "generic" ? genericName : capitalize(provider),
|
||||||
username: email,
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Trans
|
<Trans
|
||||||
i18nKey="logoutUsernameSubtitle"
|
i18nKey="logoutUsernameSubtitle"
|
||||||
t={t}
|
t={t}
|
||||||
components={{ Code: <Code /> }}
|
components={{
|
||||||
|
code: <code />,
|
||||||
|
}}
|
||||||
values={{
|
values={{
|
||||||
username: username,
|
username,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Text>
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardFooter className="flex flex-col items-stretch">
|
||||||
<Button
|
<Button
|
||||||
fullWidth
|
|
||||||
mt="xl"
|
|
||||||
onClick={() => logoutMutation.mutate()}
|
|
||||||
loading={logoutMutation.isPending}
|
loading={logoutMutation.isPending}
|
||||||
|
onClick={() => logoutMutation.mutate()}
|
||||||
>
|
>
|
||||||
{t("logoutTitle")}
|
{t("logoutTitle")}
|
||||||
</Button>
|
</Button>
|
||||||
</Paper>
|
</CardFooter>
|
||||||
</Layout>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,20 +1,34 @@
|
|||||||
import { Button, Paper, Text } from "@mantine/core";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Layout } from "../components/layouts/layout";
|
import {
|
||||||
|
Card,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useNavigate } from "react-router";
|
||||||
|
|
||||||
export const NotFoundPage = () => {
|
export const NotFoundPage = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleRedirect = () => {
|
||||||
|
setLoading(true);
|
||||||
|
navigate("/");
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Card className="min-w-xs sm:min-w-sm">
|
||||||
<Paper shadow="md" p={30} mt={30} radius="md" withBorder>
|
<CardHeader>
|
||||||
<Text size="xl" fw={700}>
|
<CardTitle className="text-3xl">{t("notFoundTitle")}</CardTitle>
|
||||||
{t("notFoundTitle")}
|
<CardDescription>{t("notFoundSubtitle")}</CardDescription>
|
||||||
</Text>
|
</CardHeader>
|
||||||
<Text>{t("notFoundSubtitle")}</Text>
|
<CardFooter className="flex flex-col items-stretch">
|
||||||
<Button fullWidth mt="xl" onClick={() => window.location.replace("/")}>
|
<Button onClick={handleRedirect} loading={loading}>{t("notFoundButton")}</Button>
|
||||||
{t("notFoundButton")}
|
</CardFooter>
|
||||||
</Button>
|
</Card>
|
||||||
</Paper>
|
|
||||||
</Layout>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,66 +1,75 @@
|
|||||||
import { Navigate } from "react-router";
|
import { TotpForm } from "@/components/auth/totp-form";
|
||||||
import { useUserContext } from "../context/user-context";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Title, Paper, Text } from "@mantine/core";
|
import {
|
||||||
import { Layout } from "../components/layouts/layout";
|
Card,
|
||||||
import { TotpForm } from "../components/auth/totp-form";
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { useUserContext } from "@/context/user-context";
|
||||||
|
import { TotpSchema } from "@/schemas/totp-schema";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { useId } from "react";
|
||||||
import { useAppContext } from "../context/app-context";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Navigate, useLocation } from "react-router";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export const TotpPage = () => {
|
export const TotpPage = () => {
|
||||||
const queryString = window.location.search;
|
const { totpPending } = useUserContext();
|
||||||
const params = new URLSearchParams(queryString);
|
|
||||||
const redirectUri = params.get("redirect_uri") ?? "";
|
|
||||||
|
|
||||||
const { totpPending, isLoggedIn } = useUserContext();
|
|
||||||
const { title } = useAppContext();
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
if (isLoggedIn) {
|
|
||||||
return <Navigate to={`/logout`} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!totpPending) {
|
if (!totpPending) {
|
||||||
return <Navigate to={`/login?redirect_uri=${redirectUri}`} />;
|
return <Navigate to="/" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { search } = useLocation();
|
||||||
|
const formId = useId();
|
||||||
|
|
||||||
|
const searchParams = new URLSearchParams(search);
|
||||||
|
const redirectUri = searchParams.get("redirect_uri");
|
||||||
|
|
||||||
const totpMutation = useMutation({
|
const totpMutation = useMutation({
|
||||||
mutationFn: async (totp: { code: string }) => {
|
mutationFn: (values: TotpSchema) => axios.post("/api/totp", values),
|
||||||
await axios.post("/api/totp", totp);
|
mutationKey: ["totp"],
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t("totpSuccessTitle"), {
|
||||||
|
description: t("totpSuccessSubtitle"),
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.replace(
|
||||||
|
`/continue?redirect_uri=${encodeURIComponent(redirectUri ?? "")}`,
|
||||||
|
);
|
||||||
|
}, 500);
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: () => {
|
||||||
notifications.show({
|
toast.error(t("totpFailTitle"), {
|
||||||
title: t("totpFailTitle"),
|
description: t("totpFailSubtitle"),
|
||||||
message: t("totpFailSubtitle"),
|
|
||||||
color: "red",
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
|
||||||
notifications.show({
|
|
||||||
title: t("totpSuccessTitle"),
|
|
||||||
message: t("totpSuccessSubtitle"),
|
|
||||||
color: "green",
|
|
||||||
});
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.replace(`/continue?redirect_uri=${redirectUri}`);
|
|
||||||
}, 500);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Card className="min-w-xs sm:min-w-sm">
|
||||||
<Title ta="center">{title}</Title>
|
<CardHeader>
|
||||||
<Paper shadow="md" p="xl" mt={30} radius="md" withBorder>
|
<CardTitle className="text-3xl">{t("totpTitle")}</CardTitle>
|
||||||
<Text size="lg" fw={500} mb="md" ta="center">
|
<CardDescription>{t("totpSubtitle")}</CardDescription>
|
||||||
{t("totpTitle")}
|
</CardHeader>
|
||||||
</Text>
|
<CardContent className="flex flex-col items-center">
|
||||||
<TotpForm
|
<TotpForm
|
||||||
isPending={totpMutation.isPending}
|
formId={formId}
|
||||||
onSubmit={(values) => totpMutation.mutate(values)}
|
onSubmit={(values) => totpMutation.mutate(values)}
|
||||||
|
loading={totpMutation.isPending}
|
||||||
/>
|
/>
|
||||||
</Paper>
|
</CardContent>
|
||||||
</Layout>
|
<CardFooter className="flex flex-col items-stretch">
|
||||||
|
<Button form={formId} type="submit" loading={totpMutation.isPending}>
|
||||||
|
{t("continueTitle")}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,98 +1,69 @@
|
|||||||
import { Button, Code, Paper, Text } from "@mantine/core";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Layout } from "../components/layouts/layout";
|
import {
|
||||||
import { Navigate } from "react-router";
|
Card,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { useState } from "react";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import React, { useEffect } from "react";
|
import { Navigate, useLocation, useNavigate } from "react-router";
|
||||||
import { isValidQuery } from "../utils/utils";
|
|
||||||
import { useIsMounted } from "../lib/hooks/use-is-mounted";
|
|
||||||
|
|
||||||
export const UnauthorizedPage = () => {
|
export const UnauthorizedPage = () => {
|
||||||
const queryString = window.location.search;
|
const { search } = useLocation();
|
||||||
const params = new URLSearchParams(queryString);
|
|
||||||
const username = params.get("username") ?? "";
|
|
||||||
const groupErr = params.get("groupErr") ?? "";
|
|
||||||
const resource = params.get("resource") ?? "";
|
|
||||||
|
|
||||||
const [isGroupErr, setIsGroupErr] = React.useState(false);
|
const searchParams = new URLSearchParams(search);
|
||||||
|
const username = searchParams.get("username");
|
||||||
|
const resource = searchParams.get("resource");
|
||||||
|
const groupErr = searchParams.get("groupErr");
|
||||||
|
|
||||||
const useMounted = useIsMounted();
|
if (!username) {
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (useMounted()) {
|
|
||||||
if (isValidQuery(groupErr)) {
|
|
||||||
if (groupErr === "true") {
|
|
||||||
setIsGroupErr(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsGroupErr(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setIsGroupErr(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
if (!isValidQuery(username)) {
|
|
||||||
return <Navigate to="/" />;
|
return <Navigate to="/" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isValidQuery(resource) && !isGroupErr) {
|
|
||||||
return (
|
|
||||||
<UnauthorizedLayout>
|
|
||||||
<Trans
|
|
||||||
i18nKey="unauthorizedResourceSubtitle"
|
|
||||||
t={t}
|
|
||||||
components={{ Code: <Code /> }}
|
|
||||||
values={{ resource, username }}
|
|
||||||
/>
|
|
||||||
</UnauthorizedLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isGroupErr && isValidQuery(resource)) {
|
|
||||||
return (
|
|
||||||
<UnauthorizedLayout>
|
|
||||||
<Trans
|
|
||||||
i18nKey="unauthorizedGroupsSubtitle"
|
|
||||||
t={t}
|
|
||||||
components={{ Code: <Code /> }}
|
|
||||||
values={{ username, resource }}
|
|
||||||
/>
|
|
||||||
</UnauthorizedLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<UnauthorizedLayout>
|
|
||||||
<Trans
|
|
||||||
i18nKey="unauthorizedLoginSubtitle"
|
|
||||||
t={t}
|
|
||||||
components={{ Code: <Code /> }}
|
|
||||||
values={{ username }}
|
|
||||||
/>
|
|
||||||
</UnauthorizedLayout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const UnauthorizedLayout = ({ children }: { children: React.ReactNode }) => {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const handleRedirect = () => {
|
||||||
|
setLoading(true);
|
||||||
|
navigate("/login");
|
||||||
|
};
|
||||||
|
|
||||||
|
let i18nKey = "unauthorizedLoginSubtitle";
|
||||||
|
|
||||||
|
if (resource) {
|
||||||
|
i18nKey = "unauthorizedResourceSubtitle";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupErr === "true") {
|
||||||
|
i18nKey = "unauthorizedGroupsSubtitle";
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Card className="min-w-xs sm:min-w-sm">
|
||||||
<Paper shadow="md" p={30} mt={30} radius="md" withBorder>
|
<CardHeader>
|
||||||
<Text size="xl" fw={700}>
|
<CardTitle className="text-3xl">{t("unauthorizedTitle")}</CardTitle>
|
||||||
{t("Unauthorized")}
|
<CardDescription>
|
||||||
</Text>
|
<Trans
|
||||||
<Text>{children}</Text>
|
i18nKey={i18nKey}
|
||||||
<Button
|
t={t}
|
||||||
fullWidth
|
components={{
|
||||||
mt="xl"
|
code: <code />,
|
||||||
onClick={() => window.location.replace("/login")}
|
}}
|
||||||
>
|
values={{
|
||||||
|
username,
|
||||||
|
resource,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardFooter className="flex flex-col items-stretch">
|
||||||
|
<Button onClick={handleRedirect} loading={loading}>
|
||||||
{t("unauthorizedButton")}
|
{t("unauthorizedButton")}
|
||||||
</Button>
|
</Button>
|
||||||
</Paper>
|
</CardFooter>
|
||||||
</Layout>
|
</Card>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const appContextSchema = z.object({
|
|||||||
domain: z.string(),
|
domain: z.string(),
|
||||||
forgotPasswordMessage: z.string(),
|
forgotPasswordMessage: z.string(),
|
||||||
oauthAutoRedirect: z.enum(["none", "github", "google", "generic"]),
|
oauthAutoRedirect: z.enum(["none", "github", "google", "generic"]),
|
||||||
|
backgroundImage: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppContextSchemaType = z.infer<typeof appContextSchema>;
|
export type AppContextSchema = z.infer<typeof appContextSchema>;
|
||||||
|
|||||||
@@ -5,4 +5,4 @@ export const loginSchema = z.object({
|
|||||||
password: z.string(),
|
password: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type LoginFormValues = z.infer<typeof loginSchema>;
|
export type LoginSchema = z.infer<typeof loginSchema>;
|
||||||
|
|||||||
7
frontend/src/schemas/totp-schema.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const totpSchema = z.object({
|
||||||
|
code: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TotpSchema = z.infer<typeof totpSchema>;
|
||||||
@@ -5,9 +5,9 @@ export const userContextSchema = z.object({
|
|||||||
username: z.string(),
|
username: z.string(),
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
email: z.string(),
|
email: z.string(),
|
||||||
oauth: z.boolean(),
|
|
||||||
provider: z.string(),
|
provider: z.string(),
|
||||||
|
oauth: z.boolean(),
|
||||||
totpPending: z.boolean(),
|
totpPending: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type UserContextSchemaType = z.infer<typeof userContextSchema>;
|
export type UserContextSchema = z.infer<typeof userContextSchema>;
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
export const capitalize = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
||||||
export const escapeRegex = (value: string) => value.replace(/[-\/\\^$.*+?()[\]{}|]/g, "\\$&");
|
|
||||||
export const isValidQuery = (query: string) => query && query.trim() !== "";
|
|
||||||
|
|
||||||
export const isValidRedirectUri = (value: string) => {
|
|
||||||
if (!isValidQuery(value)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
new URL(value);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
|
// Resolve paths
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
"target": "ES2020",
|
"target": "ES2020",
|
||||||
"useDefineForClassFields": true,
|
"useDefineForClassFields": true,
|
||||||
|
|||||||
@@ -3,5 +3,11 @@
|
|||||||
"references": [
|
"references": [
|
||||||
{ "path": "./tsconfig.app.json" },
|
{ "path": "./tsconfig.app.json" },
|
||||||
{ "path": "./tsconfig.node.json" }
|
{ "path": "./tsconfig.node.json" }
|
||||||
]
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react-swc";
|
import react from "@vitejs/plugin-react";
|
||||||
|
import path from "path";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": path.resolve(__dirname, "./src"),
|
||||||
|
},
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
proxy: {
|
proxy: {
|
||||||
@@ -14,5 +21,5 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
allowedHosts: true,
|
allowedHosts: true,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
7
go.mod
@@ -3,9 +3,10 @@ module tinyauth
|
|||||||
go 1.23.2
|
go 1.23.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/gin-gonic/gin v1.10.0
|
github.com/gin-gonic/gin v1.10.1
|
||||||
github.com/go-playground/validator/v10 v10.26.0
|
github.com/go-playground/validator/v10 v10.26.0
|
||||||
github.com/google/go-querystring v1.1.0
|
github.com/google/go-querystring v1.1.0
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
github.com/mdp/qrterminal/v3 v3.2.1
|
github.com/mdp/qrterminal/v3 v3.2.1
|
||||||
github.com/rs/zerolog v1.34.0
|
github.com/rs/zerolog v1.34.0
|
||||||
github.com/spf13/cobra v1.9.1
|
github.com/spf13/cobra v1.9.1
|
||||||
@@ -16,6 +17,8 @@ require (
|
|||||||
require (
|
require (
|
||||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||||
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
|
||||||
|
github.com/containerd/errdefs v1.0.0 // indirect
|
||||||
|
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||||
github.com/containerd/log v0.1.0 // indirect
|
github.com/containerd/log v0.1.0 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||||
github.com/moby/sys/atomicwriter v0.1.0 // indirect
|
github.com/moby/sys/atomicwriter v0.1.0 // indirect
|
||||||
@@ -47,7 +50,7 @@ require (
|
|||||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||||
github.com/distribution/reference v0.6.0 // indirect
|
github.com/distribution/reference v0.6.0 // indirect
|
||||||
github.com/docker/docker v28.1.1+incompatible
|
github.com/docker/docker v28.2.1+incompatible
|
||||||
github.com/docker/go-connections v0.5.0 // indirect
|
github.com/docker/go-connections v0.5.0 // indirect
|
||||||
github.com/docker/go-units v0.5.0 // indirect
|
github.com/docker/go-units v0.5.0 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
|||||||
12
go.sum
@@ -53,6 +53,10 @@ github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJn
|
|||||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||||
|
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||||
|
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||||
|
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||||
|
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
@@ -64,8 +68,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
|||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
github.com/docker/docker v28.1.1+incompatible h1:49M11BFLsVO1gxY9UX9p/zwkE/rswggs8AdFmXQw51I=
|
github.com/docker/docker v28.2.1+incompatible h1:aTSWVTDStpHbnRu0xBcGoJEjRf5EQKt6nik6Vif8sWw=
|
||||||
github.com/docker/docker v28.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
github.com/docker/docker v28.2.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
@@ -84,8 +88,8 @@ github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3G
|
|||||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||||
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
|
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
|
||||||
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
|
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
|
||||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
|||||||
@@ -28,21 +28,29 @@ var apiConfig = types.APIConfig{
|
|||||||
// Simple handlers config for tests
|
// Simple handlers config for tests
|
||||||
var handlersConfig = types.HandlersConfig{
|
var handlersConfig = types.HandlersConfig{
|
||||||
AppURL: "http://localhost:8080",
|
AppURL: "http://localhost:8080",
|
||||||
|
Domain: "localhost",
|
||||||
DisableContinue: false,
|
DisableContinue: false,
|
||||||
|
CookieSecure: false,
|
||||||
Title: "Tinyauth",
|
Title: "Tinyauth",
|
||||||
GenericName: "Generic",
|
GenericName: "Generic",
|
||||||
ForgotPasswordMessage: "Some message",
|
ForgotPasswordMessage: "Some message",
|
||||||
|
CsrfCookieName: "tinyauth-csrf",
|
||||||
|
RedirectCookieName: "tinyauth-redirect",
|
||||||
|
BackgroundImage: "https://example.com/image.png",
|
||||||
|
OAuthAutoRedirect: "none",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple auth config for tests
|
// Simple auth config for tests
|
||||||
var authConfig = types.AuthConfig{
|
var authConfig = types.AuthConfig{
|
||||||
Users: types.Users{},
|
Users: types.Users{},
|
||||||
OauthWhitelist: "",
|
OauthWhitelist: "",
|
||||||
Secret: "super-secret-api-thing-for-tests", // It is 32 chars long
|
Secret: "super-secret-api-thing-for-tests", // It is 32 chars long
|
||||||
CookieSecure: false,
|
CookieSecure: false,
|
||||||
SessionExpiry: 3600,
|
SessionExpiry: 3600,
|
||||||
LoginTimeout: 0,
|
LoginTimeout: 0,
|
||||||
LoginMaxRetries: 0,
|
LoginMaxRetries: 0,
|
||||||
|
SessionCookieName: "tinyauth-session",
|
||||||
|
Domain: "localhost",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Simple hooks config for tests
|
// Simple hooks config for tests
|
||||||
@@ -206,6 +214,9 @@ func TestAppContext(t *testing.T) {
|
|||||||
Title: "Tinyauth",
|
Title: "Tinyauth",
|
||||||
GenericName: "Generic",
|
GenericName: "Generic",
|
||||||
ForgotPasswordMessage: "Some message",
|
ForgotPasswordMessage: "Some message",
|
||||||
|
BackgroundImage: "https://example.com/image.png",
|
||||||
|
OAuthAutoRedirect: "none",
|
||||||
|
Domain: "localhost",
|
||||||
}
|
}
|
||||||
|
|
||||||
// We should get the username back
|
// We should get the username back
|
||||||
@@ -234,7 +245,7 @@ func TestUserContext(t *testing.T) {
|
|||||||
|
|
||||||
// Set the cookie
|
// Set the cookie
|
||||||
req.AddCookie(&http.Cookie{
|
req.AddCookie(&http.Cookie{
|
||||||
Name: "tinyauth",
|
Name: "tinyauth-session",
|
||||||
Value: cookie,
|
Value: cookie,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func (auth *Auth) GetSession(c *gin.Context) (*sessions.Session, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get session
|
// Get session
|
||||||
session, err := store.Get(c.Request, "tinyauth")
|
session, err := store.Get(c.Request, auth.Config.SessionCookieName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error().Err(err).Msg("Failed to get session")
|
log.Error().Err(err).Msg("Failed to get session")
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -21,3 +21,8 @@ type Claims struct {
|
|||||||
var Version = "development"
|
var Version = "development"
|
||||||
var CommitHash = "n/a"
|
var CommitHash = "n/a"
|
||||||
var BuildTimestamp = "n/a"
|
var BuildTimestamp = "n/a"
|
||||||
|
|
||||||
|
// Cookie names
|
||||||
|
var SessionCookieName = "tinyauth-session"
|
||||||
|
var CsrfCookieName = "tinyauth-csrf"
|
||||||
|
var RedirectCookieName = "tinyauth-redirect"
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ func (h *Handlers) AuthHandler(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// We are using caddy/traefik so redirect
|
// We are using caddy/traefik so redirect
|
||||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/unauthorized?%s", h.Config.AppURL, queries.Encode()))
|
c.Redirect(http.StatusPermanentRedirect, fmt.Sprintf("%s/unauthorized?%s", h.Config.AppURL, queries.Encode()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,7 +225,7 @@ func (h *Handlers) AuthHandler(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// We are using caddy/traefik so redirect
|
// We are using caddy/traefik so redirect
|
||||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/unauthorized?%s", h.Config.AppURL, queries.Encode()))
|
c.Redirect(http.StatusPermanentRedirect, fmt.Sprintf("%s/unauthorized?%s", h.Config.AppURL, queries.Encode()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -273,7 +273,7 @@ func (h *Handlers) AuthHandler(c *gin.Context) {
|
|||||||
log.Debug().Interface("redirect_uri", fmt.Sprintf("%s://%s%s", proto, host, uri)).Msg("Redirecting to login")
|
log.Debug().Interface("redirect_uri", fmt.Sprintf("%s://%s%s", proto, host, uri)).Msg("Redirecting to login")
|
||||||
|
|
||||||
// Redirect to login
|
// Redirect to login
|
||||||
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/?%s", h.Config.AppURL, queries.Encode()))
|
c.Redirect(http.StatusTemporaryRedirect, fmt.Sprintf("%s/login?%s", h.Config.AppURL, queries.Encode()))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handlers) LoginHandler(c *gin.Context) {
|
func (h *Handlers) LoginHandler(c *gin.Context) {
|
||||||
@@ -500,6 +500,7 @@ func (h *Handlers) AppHandler(c *gin.Context) {
|
|||||||
GenericName: h.Config.GenericName,
|
GenericName: h.Config.GenericName,
|
||||||
Domain: h.Config.Domain,
|
Domain: h.Config.Domain,
|
||||||
ForgotPasswordMessage: h.Config.ForgotPasswordMessage,
|
ForgotPasswordMessage: h.Config.ForgotPasswordMessage,
|
||||||
|
BackgroundImage: h.Config.BackgroundImage,
|
||||||
OAuthAutoRedirect: h.Config.OAuthAutoRedirect,
|
OAuthAutoRedirect: h.Config.OAuthAutoRedirect,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -580,7 +581,7 @@ func (h *Handlers) OauthUrlHandler(c *gin.Context) {
|
|||||||
log.Debug().Msg("Got auth URL")
|
log.Debug().Msg("Got auth URL")
|
||||||
|
|
||||||
// Set CSRF cookie
|
// Set CSRF cookie
|
||||||
c.SetCookie("tinyauth-csrf", state, int(time.Hour.Seconds()), "/", "", h.Config.CookieSecure, true)
|
c.SetCookie(h.Config.CsrfCookieName, state, int(time.Hour.Seconds()), "/", "", h.Config.CookieSecure, true)
|
||||||
|
|
||||||
// Get redirect URI
|
// Get redirect URI
|
||||||
redirectURI := c.Query("redirect_uri")
|
redirectURI := c.Query("redirect_uri")
|
||||||
@@ -588,7 +589,7 @@ func (h *Handlers) OauthUrlHandler(c *gin.Context) {
|
|||||||
// Set redirect cookie if redirect URI is provided
|
// Set redirect cookie if redirect URI is provided
|
||||||
if redirectURI != "" {
|
if redirectURI != "" {
|
||||||
log.Debug().Str("redirectURI", redirectURI).Msg("Setting redirect cookie")
|
log.Debug().Str("redirectURI", redirectURI).Msg("Setting redirect cookie")
|
||||||
c.SetCookie("tinyauth-redirect", redirectURI, int(time.Hour.Seconds()), "/", "", h.Config.CookieSecure, true)
|
c.SetCookie(h.Config.RedirectCookieName, redirectURI, int(time.Hour.Seconds()), "/", "", h.Config.CookieSecure, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return auth URL
|
// Return auth URL
|
||||||
@@ -619,7 +620,7 @@ func (h *Handlers) OauthCallbackHandler(c *gin.Context) {
|
|||||||
state := c.Query("state")
|
state := c.Query("state")
|
||||||
|
|
||||||
// Get CSRF cookie
|
// Get CSRF cookie
|
||||||
csrfCookie, err := c.Cookie("tinyauth-csrf")
|
csrfCookie, err := c.Cookie(h.Config.CsrfCookieName)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Debug().Msg("No CSRF cookie")
|
log.Debug().Msg("No CSRF cookie")
|
||||||
@@ -637,7 +638,7 @@ func (h *Handlers) OauthCallbackHandler(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clean up CSRF cookie
|
// Clean up CSRF cookie
|
||||||
c.SetCookie("tinyauth-csrf", "", -1, "/", "", h.Config.CookieSecure, true)
|
c.SetCookie(h.Config.CsrfCookieName, "", -1, "/", "", h.Config.CookieSecure, true)
|
||||||
|
|
||||||
// Get code
|
// Get code
|
||||||
code := c.Query("code")
|
code := c.Query("code")
|
||||||
@@ -736,7 +737,7 @@ func (h *Handlers) OauthCallbackHandler(c *gin.Context) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Check if we have a redirect URI
|
// Check if we have a redirect URI
|
||||||
redirectCookie, err := c.Cookie("tinyauth-redirect")
|
redirectCookie, err := c.Cookie(h.Config.RedirectCookieName)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Debug().Msg("No redirect cookie")
|
log.Debug().Msg("No redirect cookie")
|
||||||
@@ -761,7 +762,7 @@ func (h *Handlers) OauthCallbackHandler(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clean up redirect cookie
|
// Clean up redirect cookie
|
||||||
c.SetCookie("tinyauth-redirect", "", -1, "/", "", h.Config.CookieSecure, true)
|
c.SetCookie(h.Config.RedirectCookieName, "", -1, "/", "", h.Config.CookieSecure, true)
|
||||||
|
|
||||||
// Redirect to continue with the redirect URI
|
// Redirect to continue with the redirect URI
|
||||||
c.Redirect(http.StatusPermanentRedirect, fmt.Sprintf("%s/continue?%s", h.Config.AppURL, queries.Encode()))
|
c.Redirect(http.StatusPermanentRedirect, fmt.Sprintf("%s/continue?%s", h.Config.AppURL, queries.Encode()))
|
||||||
|
|||||||
@@ -3,28 +3,48 @@ package oauth
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/tls"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewOAuth(config oauth2.Config) *OAuth {
|
func NewOAuth(config oauth2.Config, insecureSkipVerify bool) *OAuth {
|
||||||
return &OAuth{
|
return &OAuth{
|
||||||
Config: config,
|
Config: config,
|
||||||
|
InsecureSkipVerify: insecureSkipVerify,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type OAuth struct {
|
type OAuth struct {
|
||||||
Config oauth2.Config
|
Config oauth2.Config
|
||||||
Context context.Context
|
Context context.Context
|
||||||
Token *oauth2.Token
|
Token *oauth2.Token
|
||||||
Verifier string
|
Verifier string
|
||||||
|
InsecureSkipVerify bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (oauth *OAuth) Init() {
|
func (oauth *OAuth) Init() {
|
||||||
// Create a new context and verifier
|
// Create transport with TLS
|
||||||
|
transport := &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{
|
||||||
|
InsecureSkipVerify: oauth.InsecureSkipVerify,
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new context
|
||||||
oauth.Context = context.Background()
|
oauth.Context = context.Background()
|
||||||
|
|
||||||
|
// Create the HTTP client with the transport
|
||||||
|
httpClient := &http.Client{
|
||||||
|
Transport: transport,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the HTTP client in the context
|
||||||
|
oauth.Context = context.WithValue(oauth.Context, oauth2.HTTPClient, httpClient)
|
||||||
|
// Create the verifier
|
||||||
oauth.Verifier = oauth2.GenerateVerifier()
|
oauth.Verifier = oauth2.GenerateVerifier()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func (providers *Providers) Init() {
|
|||||||
RedirectURL: fmt.Sprintf("%s/api/oauth/callback/github", providers.Config.AppURL),
|
RedirectURL: fmt.Sprintf("%s/api/oauth/callback/github", providers.Config.AppURL),
|
||||||
Scopes: GithubScopes(),
|
Scopes: GithubScopes(),
|
||||||
Endpoint: endpoints.GitHub,
|
Endpoint: endpoints.GitHub,
|
||||||
})
|
}, false)
|
||||||
|
|
||||||
// Initialize the oauth provider
|
// Initialize the oauth provider
|
||||||
providers.Github.Init()
|
providers.Github.Init()
|
||||||
@@ -53,7 +53,7 @@ func (providers *Providers) Init() {
|
|||||||
RedirectURL: fmt.Sprintf("%s/api/oauth/callback/google", providers.Config.AppURL),
|
RedirectURL: fmt.Sprintf("%s/api/oauth/callback/google", providers.Config.AppURL),
|
||||||
Scopes: GoogleScopes(),
|
Scopes: GoogleScopes(),
|
||||||
Endpoint: endpoints.Google,
|
Endpoint: endpoints.Google,
|
||||||
})
|
}, false)
|
||||||
|
|
||||||
// Initialize the oauth provider
|
// Initialize the oauth provider
|
||||||
providers.Google.Init()
|
providers.Google.Init()
|
||||||
@@ -73,7 +73,7 @@ func (providers *Providers) Init() {
|
|||||||
AuthURL: providers.Config.GenericAuthURL,
|
AuthURL: providers.Config.GenericAuthURL,
|
||||||
TokenURL: providers.Config.GenericTokenURL,
|
TokenURL: providers.Config.GenericTokenURL,
|
||||||
},
|
},
|
||||||
})
|
}, providers.Config.GenericSkipSSL)
|
||||||
|
|
||||||
// Initialize the oauth provider
|
// Initialize the oauth provider
|
||||||
providers.Generic.Init()
|
providers.Generic.Init()
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ type AppContext struct {
|
|||||||
GenericName string `json:"genericName"`
|
GenericName string `json:"genericName"`
|
||||||
Domain string `json:"domain"`
|
Domain string `json:"domain"`
|
||||||
ForgotPasswordMessage string `json:"forgotPasswordMessage"`
|
ForgotPasswordMessage string `json:"forgotPasswordMessage"`
|
||||||
|
BackgroundImage string `json:"backgroundImage"`
|
||||||
OAuthAutoRedirect string `json:"oauthAutoRedirect"`
|
OAuthAutoRedirect string `json:"oauthAutoRedirect"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ type Config struct {
|
|||||||
GenericTokenURL string `mapstructure:"generic-token-url"`
|
GenericTokenURL string `mapstructure:"generic-token-url"`
|
||||||
GenericUserURL string `mapstructure:"generic-user-url"`
|
GenericUserURL string `mapstructure:"generic-user-url"`
|
||||||
GenericName string `mapstructure:"generic-name"`
|
GenericName string `mapstructure:"generic-name"`
|
||||||
|
GenericSkipSSL bool `mapstructure:"generic-skip-ssl"`
|
||||||
DisableContinue bool `mapstructure:"disable-continue"`
|
DisableContinue bool `mapstructure:"disable-continue"`
|
||||||
OAuthWhitelist string `mapstructure:"oauth-whitelist"`
|
OAuthWhitelist string `mapstructure:"oauth-whitelist"`
|
||||||
OAuthAutoRedirect string `mapstructure:"oauth-auto-redirect" validate:"oneof=none github google generic"`
|
OAuthAutoRedirect string `mapstructure:"oauth-auto-redirect" validate:"oneof=none github google generic"`
|
||||||
@@ -34,6 +35,7 @@ type Config struct {
|
|||||||
LoginTimeout int `mapstructure:"login-timeout"`
|
LoginTimeout int `mapstructure:"login-timeout"`
|
||||||
LoginMaxRetries int `mapstructure:"login-max-retries"`
|
LoginMaxRetries int `mapstructure:"login-max-retries"`
|
||||||
FogotPasswordMessage string `mapstructure:"forgot-password-message" validate:"required"`
|
FogotPasswordMessage string `mapstructure:"forgot-password-message" validate:"required"`
|
||||||
|
BackgroundImage string `mapstructure:"background-image" validate:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server configuration
|
// Server configuration
|
||||||
@@ -45,7 +47,10 @@ type HandlersConfig struct {
|
|||||||
GenericName string
|
GenericName string
|
||||||
Title string
|
Title string
|
||||||
ForgotPasswordMessage string
|
ForgotPasswordMessage string
|
||||||
|
BackgroundImage string
|
||||||
OAuthAutoRedirect string
|
OAuthAutoRedirect string
|
||||||
|
CsrfCookieName string
|
||||||
|
RedirectCookieName string
|
||||||
}
|
}
|
||||||
|
|
||||||
// OAuthConfig is the configuration for the providers
|
// OAuthConfig is the configuration for the providers
|
||||||
@@ -60,6 +65,7 @@ type OAuthConfig struct {
|
|||||||
GenericAuthURL string
|
GenericAuthURL string
|
||||||
GenericTokenURL string
|
GenericTokenURL string
|
||||||
GenericUserURL string
|
GenericUserURL string
|
||||||
|
GenericSkipSSL bool
|
||||||
AppURL string
|
AppURL string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,14 +77,15 @@ type APIConfig struct {
|
|||||||
|
|
||||||
// AuthConfig is the configuration for the auth service
|
// AuthConfig is the configuration for the auth service
|
||||||
type AuthConfig struct {
|
type AuthConfig struct {
|
||||||
Users Users
|
Users Users
|
||||||
OauthWhitelist string
|
OauthWhitelist string
|
||||||
SessionExpiry int
|
SessionExpiry int
|
||||||
Secret string
|
Secret string
|
||||||
CookieSecure bool
|
CookieSecure bool
|
||||||
Domain string
|
Domain string
|
||||||
LoginTimeout int
|
LoginTimeout int
|
||||||
LoginMaxRetries int
|
LoginMaxRetries int
|
||||||
|
SessionCookieName string
|
||||||
}
|
}
|
||||||
|
|
||||||
// HooksConfig is the configuration for the hooks service
|
// HooksConfig is the configuration for the hooks service
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"tinyauth/internal/constants"
|
"tinyauth/internal/constants"
|
||||||
"tinyauth/internal/types"
|
"tinyauth/internal/types"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -254,16 +255,16 @@ func ParseUser(user string) (types.User, error) {
|
|||||||
// Check if the user has a totp secret
|
// Check if the user has a totp secret
|
||||||
if len(userSplit) == 2 {
|
if len(userSplit) == 2 {
|
||||||
return types.User{
|
return types.User{
|
||||||
Username: userSplit[0],
|
Username: strings.TrimSpace(userSplit[0]),
|
||||||
Password: userSplit[1],
|
Password: strings.TrimSpace(userSplit[1]),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the user struct
|
// Return the user struct
|
||||||
return types.User{
|
return types.User{
|
||||||
Username: userSplit[0],
|
Username: strings.TrimSpace(userSplit[0]),
|
||||||
Password: userSplit[1],
|
Password: strings.TrimSpace(userSplit[1]),
|
||||||
TotpSecret: userSplit[2],
|
TotpSecret: strings.TrimSpace(userSplit[2]),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -344,3 +345,18 @@ func SanitizeHeader(header string) string {
|
|||||||
return -1
|
return -1
|
||||||
}, header)
|
}, header)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Generate a static identifier from a string
|
||||||
|
func GenerateIdentifier(str string) string {
|
||||||
|
// Create a new UUID
|
||||||
|
uuid := uuid.NewSHA1(uuid.NameSpaceURL, []byte(str))
|
||||||
|
|
||||||
|
// Convert the UUID to a string
|
||||||
|
uuidString := uuid.String()
|
||||||
|
|
||||||
|
// Show the UUID
|
||||||
|
log.Debug().Str("uuid", uuidString).Msg("Generated UUID")
|
||||||
|
|
||||||
|
// Convert the UUID to a string
|
||||||
|
return strings.Split(uuidString, "-")[0]
|
||||||
|
}
|
||||||
|
|||||||