Compare commits

...

30 Commits

Author SHA1 Message Date
Stavros
f441645e36 chore: bot suggestion
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-05-27 16:39:01 +03:00
Stavros
35ae69791c chore: fix typo 2025-05-25 12:50:49 +03:00
Stavros
1dfa54305f feat: allow generic provider to use untrusted SSL certificates 2025-05-25 12:48:57 +03:00
Stavros
3c3bd719db fix: show correct generic name in login screen 2025-05-24 16:19:56 +03:00
Stavros
a6aa97bcfa chore: remove url requirement in background image 2025-05-24 16:02:40 +03:00
Stavros
1a7b6cfb99 chore: remove healthcheck from dockerfile 2025-05-22 22:36:29 +03:00
Stavros
da7cebdfed chore: update readme 2025-05-21 20:04:45 +03:00
Stavros
318f00993e Feat/new UI (#153)
* wip

* feat: make forms functional

* feat: finalize pages

* chore: remove unused translations

* feat: app context

* feat: user context

* feat: finalize username login

* fix: use correct tab order in login form

* feat: add oauth logic

* chore: update readme and assets

* chore: rename docs back to assets

* feat: favicons

* feat: custom background image config option

* chore: add acknowledgements for background image

* feat: sanitize redirect URL

* feat: sanitize redirect URL on check

* chore: fix dependabot config

* refactor: bot suggestions

* fix: correctly redirect to app and check for untrusted redirects

* fix: run oauth auto redirect only when there is a redirect URI

* refactor: change select color

* fix: fix dockerfiles

* fix: fix hook rendering

* chore: remove translations cdn

* chore: formatting

* feat: validate api response against zod schema

* fix: use axios error instead of generic error in login page
2025-05-20 17:17:12 +03:00
dependabot[bot]
4d061db29a chore(deps): bump github.com/pquerna/otp from 1.4.0 to 1.5.0 (#154)
Bumps [github.com/pquerna/otp](https://github.com/pquerna/otp) from 1.4.0 to 1.5.0.
- [Release notes](https://github.com/pquerna/otp/releases)
- [Commits](https://github.com/pquerna/otp/compare/v1.4.0...v1.5.0)

---
updated-dependencies:
- dependency-name: github.com/pquerna/otp
  dependency-version: 1.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-20 16:40:55 +03:00
Stavros
39abb4fa02 fix: fix dependabot config 2025-05-20 16:40:23 +03:00
Stavros
91e3bbc9d9 refactor: store version in constants 2025-05-20 16:39:27 +03:00
Stavros
415eea6fab fix: use current context in release workflows 2025-05-15 17:08:56 +03:00
Stavros
ad5aa30b9f fix: checkout in the create release step in the nightly workflow 2025-05-15 16:49:54 +03:00
Stavros
dcd6c5667d fix: ensure create release has succeeded in nightly 2025-05-15 16:44:48 +03:00
Stavros
ff48fa320e feat: nightly release workflow 2025-05-15 16:41:26 +03:00
Stavros
5fe70b4bce chore: remove commit activity 2025-05-14 21:06:30 +03:00
dependabot[bot]
854c10d13f chore(deps): bump @mantine/form from 7.17.7 to 8.0.0 in /frontend (#145)
Bumps [@mantine/form](https://github.com/mantinedev/mantine/tree/HEAD/packages/@mantine/form) from 7.17.7 to 8.0.0.
- [Release notes](https://github.com/mantinedev/mantine/releases)
- [Changelog](https://github.com/mantinedev/mantine/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mantinedev/mantine/commits/8.0.0/packages/@mantine/form)

---
updated-dependencies:
- dependency-name: "@mantine/form"
  dependency-version: 8.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-06 18:42:08 +03:00
dependabot[bot]
7e158eb0d3 chore(deps): bump golang.org/x/oauth2 from 0.29.0 to 0.30.0 (#147)
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.29.0 to 0.30.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.29.0...v0.30.0)

---
updated-dependencies:
- dependency-name: golang.org/x/oauth2
  dependency-version: 0.30.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-06 18:41:53 +03:00
Stavros
8b6194217e chore: group minor and patch dep updates 2025-05-06 18:41:32 +03:00
dependabot[bot]
15d3bffd6d chore(deps): bump @mantine/hooks from 7.17.7 to 8.0.0 in /frontend (#144)
Bumps [@mantine/hooks](https://github.com/mantinedev/mantine/tree/HEAD/packages/@mantine/hooks) from 7.17.7 to 8.0.0.
- [Release notes](https://github.com/mantinedev/mantine/releases)
- [Changelog](https://github.com/mantinedev/mantine/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mantinedev/mantine/commits/8.0.0/packages/@mantine/hooks)

---
updated-dependencies:
- dependency-name: "@mantine/hooks"
  dependency-version: 8.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-06 18:38:45 +03:00
dependabot[bot]
5dd3bd5f7e chore(deps): bump golang.org/x/crypto from 0.37.0 to 0.38.0 (#146)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.37.0 to 0.38.0.
- [Commits](https://github.com/golang/crypto/compare/v0.37.0...v0.38.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.38.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-06 18:36:54 +03:00
dependabot[bot]
1d2530db6d chore(deps): bump @mantine/core from 7.17.7 to 8.0.0 in /frontend (#143)
Bumps [@mantine/core](https://github.com/mantinedev/mantine/tree/HEAD/packages/@mantine/core) from 7.17.7 to 8.0.0.
- [Release notes](https://github.com/mantinedev/mantine/releases)
- [Changelog](https://github.com/mantinedev/mantine/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mantinedev/mantine/commits/8.0.0/packages/@mantine/core)

---
updated-dependencies:
- dependency-name: "@mantine/core"
  dependency-version: 8.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-06 18:36:08 +03:00
dependabot[bot]
b04af33fdb chore(deps): bump @mantine/notifications in /frontend (#142)
Bumps [@mantine/notifications](https://github.com/mantinedev/mantine/tree/HEAD/packages/@mantine/notifications) from 7.17.7 to 8.0.0.
- [Release notes](https://github.com/mantinedev/mantine/releases)
- [Changelog](https://github.com/mantinedev/mantine/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mantinedev/mantine/commits/8.0.0/packages/@mantine/notifications)

---
updated-dependencies:
- dependency-name: "@mantine/notifications"
  dependency-version: 8.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-06 18:35:51 +03:00
dependabot[bot]
f3db19930a chore(deps): bump oven/bun from 1.2.11-alpine to 1.2.12-alpine (#140)
Bumps oven/bun from 1.2.11-alpine to 1.2.12-alpine.

---
updated-dependencies:
- dependency-name: oven/bun
  dependency-version: 1.2.12-alpine
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-06 18:35:29 +03:00
Stavros
5a601277ab refactor: remove www authenticate header 2025-05-06 17:01:46 +03:00
github-actions[bot]
d5615bdcec docs: regenerate readme sponsors list (#138)
Co-authored-by: GitHub <noreply@github.com>
2025-05-03 23:57:18 +03:00
Stavros
58588d6663 fix: allow docker api negotiation 2025-05-03 16:37:37 +00:00
Stavros
2db7795eb7 fix: use email in oauth whitelist check 2025-05-02 15:16:57 +03:00
github-actions[bot]
dd5a9e2216 docs: regenerate readme sponsors list (#131)
Co-authored-by: GitHub <noreply@github.com>
2025-05-01 23:43:44 +03:00
Stavros
00d1543f08 fix: include past sponsors in readme 2025-05-01 23:41:27 +03:00
102 changed files with 3508 additions and 6099 deletions

View File

@@ -28,4 +28,6 @@ LOGIN_MAX_RETRIES=5
LOG_LEVEL=0
APP_TITLE=Tinyauth SSO
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

View File

@@ -2,12 +2,24 @@ version: 2
updates:
- package-ecosystem: "bun"
directory: "/frontend"
groups:
minor-patch:
update-types:
- "patch"
- "minor"
schedule:
interval: "daily"
- package-ecosystem: "gomod"
directory: "/"
groups:
minor-patch:
update-types:
- "patch"
- "minor"
schedule:
interval: "daily"
- package-ecosystem: "docker"
directory: "/"
schedule:

View File

@@ -12,21 +12,23 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Go
- name: Setup bun
uses: oven-sh/setup-bun@v2
- name: Setup go
uses: actions/setup-go@v5
with:
go-version: "^1.23.2"
- name: Setup bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install frontend dependencies
run: |
cd frontend
bun install
- name: Set version
run: |
echo testing > internal/assets/version
- name: Build frontend
run: |
cd frontend

298
.github/workflows/nightly.yml vendored Normal file
View File

@@ -0,0 +1,298 @@
name: Nightly Release
on:
workflow_dispatch:
jobs:
create-release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Delete old release
run: gh release delete --cleanup-tag --yes nightly || echo release not found
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
OWNER: ${{ github.repository_owner }}
REPO: ${{ github.event.repository.name }}
- name: Create release
uses: softprops/action-gh-release@v2
with:
prerelease: true
tag_name: nightly
generate-metadata:
runs-on: ubuntu-latest
needs: create-release
outputs:
VERSION: ${{ steps.metadata.outputs.VERSION }}
COMMIT_HASH: ${{ steps.metadata.outputs.COMMIT_HASH }}
BUILD_TIMESTAMP: ${{ steps.metadata.outputs.BUILD_TIMESTAMP }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: nightly
- name: Generate metadata
id: metadata
run: |
echo "VERSION=nightly" >> "$GITHUB_OUTPUT"
echo "COMMIT_HASH=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
echo "BUILD_TIMESTAMP=$(date '+%Y-%m-%dT%H:%M:%S')" >> "$GITHUB_OUTPUT"
binary-build:
runs-on: ubuntu-latest
needs:
- create-release
- generate-metadata
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: nightly
- name: Install bun
uses: oven-sh/setup-bun@v2
- name: Install go
uses: actions/setup-go@v5
with:
go-version: "^1.23.2"
- name: Install frontend dependencies
run: |
cd frontend
bun install
- name: Install backend dependencies
run: |
go mod download
- name: Build frontend
run: |
cd frontend
bun run build
- name: Build
run: |
cp -r frontend/dist internal/assets/dist
go build -ldflags "-s -w -X tinyauth/internal/constants.Version=${{ needs.generate-metadata.outputs.VERSION }} -X tinyauth/internal/constants.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X tinyauth/internal/constants.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-amd64
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: tinyauth-amd64
path: tinyauth-amd64
binary-build-arm:
runs-on: ubuntu-24.04-arm
needs:
- create-release
- generate-metadata
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: nightly
- name: Install bun
uses: oven-sh/setup-bun@v2
- name: Install go
uses: actions/setup-go@v5
with:
go-version: "^1.23.2"
- name: Install frontend dependencies
run: |
cd frontend
bun install
- name: Install backend dependencies
run: |
go mod download
- name: Build frontend
run: |
cd frontend
bun run build
- name: Build
run: |
cp -r frontend/dist internal/assets/dist
go build -ldflags "-s -w -X tinyauth/internal/constants.Version=${{ needs.generate-metadata.outputs.VERSION }} -X tinyauth/internal/constants.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X tinyauth/internal/constants.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-arm64
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: tinyauth-arm64
path: tinyauth-arm64
image-build:
runs-on: ubuntu-latest
needs:
- create-release
- generate-metadata
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: nightly
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/tinyauth
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
id: build
with:
platforms: linux/amd64
labels: ${{ steps.meta.outputs.labels }}
tags: ghcr.io/${{ github.repository_owner }}/tinyauth
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
build-args: |
VERSION=${{ needs.generate-metadata.outputs.VERSION }}
COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }}
BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-linux-amd64
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
image-build-arm:
runs-on: ubuntu-24.04-arm
needs:
- create-release
- generate-metadata
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: nightly
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/tinyauth
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set version
run: |
echo nightly > internal/assets/version
- name: Build and push
uses: docker/build-push-action@v6
id: build
with:
platforms: linux/arm64
labels: ${{ steps.meta.outputs.labels }}
tags: ghcr.io/${{ github.repository_owner }}/tinyauth
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
build-args: |
VERSION=${{ needs.generate-metadata.outputs.VERSION }}
COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }}
BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}
- name: Export digest
run: |
mkdir -p ${{ runner.temp }}/digests
digest="${{ steps.build.outputs.digest }}"
touch "${{ runner.temp }}/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v4
with:
name: digests-linux-arm64
path: ${{ runner.temp }}/digests/*
if-no-files-found: error
retention-days: 1
image-merge:
runs-on: ubuntu-latest
needs:
- image-build
- image-build-arm
steps:
- name: Download digests
uses: actions/download-artifact@v4
with:
path: ${{ runner.temp }}/digests
pattern: digests-*
merge-multiple: true
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository_owner }}/tinyauth
tags: |
type=raw,nightly
- name: Create manifest list and push
working-directory: ${{ runner.temp }}/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf 'ghcr.io/${{ github.repository_owner }}/tinyauth@sha256:%s ' *)
update-release:
runs-on: ubuntu-latest
needs:
- binary-build
- binary-build-arm
steps:
- uses: actions/download-artifact@v4
with:
pattern: tinyauth-*
path: binaries
merge-multiple: true
- name: Release
uses: softprops/action-gh-release@v2
with:
files: binaries/*
tag_name: nightly

View File

@@ -6,17 +6,38 @@ on:
- "v*"
jobs:
generate-metadata:
runs-on: ubuntu-latest
outputs:
VERSION: ${{ steps.metadata.outputs.VERSION }}
COMMIT_HASH: ${{ steps.metadata.outputs.COMMIT_HASH }}
BUILD_TIMESTAMP: ${{ steps.metadata.outputs.BUILD_TIMESTAMP }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: nightly
- name: Generate metadata
id: metadata
run: |
echo "VERSION=nightly" >> "$GITHUB_OUTPUT"
echo "COMMIT_HASH=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
echo "BUILD_TIMESTAMP=$(date '+%Y-%m-%dT%H:%M:%S')" >> "$GITHUB_OUTPUT"
binary-build:
runs-on: ubuntu-latest
needs:
- generate-metadata
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install bun
uses: oven-sh/setup-bun@v2
- uses: actions/setup-go@v5
- name: Install go
uses: actions/setup-go@v5
with:
go-version: "^1.23.2"
@@ -27,7 +48,7 @@ jobs:
- name: Install backend dependencies
run: |
go mod tidy
go mod download
- name: Build frontend
run: |
@@ -37,7 +58,7 @@ jobs:
- name: Build
run: |
cp -r frontend/dist internal/assets/dist
CGO_ENABLED=0 go build -ldflags "-s -w" -o tinyauth-amd64
go build -ldflags "-s -w -X tinyauth/internal/constants.Version=${{ needs.generate-metadata.outputs.VERSION }} -X tinyauth/internal/constants.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X tinyauth/internal/constants.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-amd64
- name: Upload artifact
uses: actions/upload-artifact@v4
@@ -47,15 +68,17 @@ jobs:
binary-build-arm:
runs-on: ubuntu-24.04-arm
needs:
- generate-metadata
steps:
- name: Checkout
uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install bun
uses: oven-sh/setup-bun@v2
- uses: actions/setup-go@v5
- name: Install go
uses: actions/setup-go@v5
with:
go-version: "^1.23.2"
@@ -66,7 +89,7 @@ jobs:
- name: Install backend dependencies
run: |
go mod tidy
go mod download
- name: Build frontend
run: |
@@ -76,7 +99,7 @@ jobs:
- name: Build
run: |
cp -r frontend/dist internal/assets/dist
CGO_ENABLED=0 go build -ldflags "-s -w" -o tinyauth-arm64
go build -ldflags "-s -w -X tinyauth/internal/constants.Version=${{ needs.generate-metadata.outputs.VERSION }} -X tinyauth/internal/constants.CommitHash=${{ needs.generate-metadata.outputs.COMMIT_HASH }} -X tinyauth/internal/constants.BuildTimestamp=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}" -o tinyauth-arm64
- name: Upload artifact
uses: actions/upload-artifact@v4
@@ -86,6 +109,8 @@ jobs:
image-build:
runs-on: ubuntu-latest
needs:
- generate-metadata
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -114,6 +139,10 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
tags: ghcr.io/${{ github.repository_owner }}/tinyauth
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
build-args: |
VERSION=${{ needs.generate-metadata.outputs.VERSION }}
COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }}
BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}
- name: Export digest
run: |
@@ -131,6 +160,8 @@ jobs:
image-build-arm:
runs-on: ubuntu-24.04-arm
needs:
- generate-metadata
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -159,6 +190,10 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
tags: ghcr.io/${{ github.repository_owner }}/tinyauth
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
build-args: |
VERSION=${{ needs.generate-metadata.outputs.VERSION }}
COMMIT_HASH=${{ needs.generate-metadata.outputs.COMMIT_HASH }}
BUILD_TIMESTAMP=${{ needs.generate-metadata.outputs.BUILD_TIMESTAMP }}
- name: Export digest
run: |

View File

@@ -13,7 +13,8 @@ jobs:
uses: JamesIves/github-sponsors-readme-action@v1
with:
token: ${{ secrets.SPONSORS_GENERATOR_PAT }}
file: README.md
active-only: false
file: "README.md"
template: '<a href="https://github.com/{{{ login }}}"><img src="{{{ avatarUrl }}}" width="64px" alt="User avatar: {{{ login }}}" /></a>&nbsp;&nbsp;'
- name: Create Pull Request

View File

@@ -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

5
.gitignore vendored
View File

@@ -24,4 +24,7 @@ secret_oauth.txt
.env
# tmp directory
tmp
tmp
# version files
internal/assets/version

View File

@@ -1,6 +1,6 @@
# Contributing
Contributing is relatively easy, you just need to follow the steps carefully and you will be up and running with a development server in less than 5 minutes.
Contributing is relatively easy, you just need to follow the steps below and you will be up and running with a development server in less than five minutes.
## Requirements
@@ -20,7 +20,7 @@ cd tinyauth
## Install requirements
Although you will not need the requirements in your machine since the development will happen in docker, I still recommend to install them because this way you will not have import errors, to install the go requirements, run:
Although you will not need the requirements in your machine since the development will happen in docker, I still recommend to install them because this way you will not have import errors. To install the go requirements run:
```sh
go mod tidy
@@ -35,7 +35,7 @@ bun install
## Create your `.env` file
In order to configure the app you need to create an environment file, this can be done by copying the `.env.example` file to `.env` and modifying the environment variables inside to suit your needs.
In order to configure the app you need to create an environment file, this can be done by copying the `.env.example` file to `.env` and modifying the environment variables to suit your needs.
## Developing
@@ -46,11 +46,14 @@ I have designed the development workflow to be entirely in docker, this is becau
dev.example.com -> 127.0.0.1
```
Then you can just make sure the domains are correct in the example docker compose file and run:
> [!TIP]
> You can use [sslip.io](https://sslip.io) as a domain if you don't have one to develop with.
Then you can just make sure the domains are correct in the development docker compose file and run:
```sh
docker compose -f docker-compose.dev.yml up --build
```
> [!NOTE]
> I would recommend copying the example `docker-compose.dev.yml` into a `docker-compose.test.yml` file, so as you don't accidentally commit any sensitive information.
> I recommend copying the example `docker-compose.dev.yml` into a `docker-compose.test.yml` file, so as you don't accidentally commit any sensitive information.

View File

@@ -1,10 +1,15 @@
# Arguments
ARG VERSION
ARG COMMIT_HASH
ARG BUILD_TIMESTAMP
# Site builder
FROM oven/bun:1.2.11-alpine AS frontend-builder
FROM oven/bun:1.2.12-alpine AS frontend-builder
WORKDIR /frontend
COPY ./frontend/package.json ./
COPY ./frontend/bun.lockb ./
COPY ./frontend/bun.lock ./
RUN bun install
@@ -16,7 +21,6 @@ COPY ./frontend/tsconfig.json ./
COPY ./frontend/tsconfig.app.json ./
COPY ./frontend/tsconfig.node.json ./
COPY ./frontend/vite.config.ts ./
COPY ./frontend/postcss.config.cjs ./
RUN bun run build
@@ -35,8 +39,8 @@ COPY ./cmd ./cmd
COPY ./internal ./internal
COPY --from=frontend-builder /frontend/dist ./internal/assets/dist
RUN CGO_ENABLED=0 go build -ldflags "-s -w"
RUN go build -ldflags "-s -w -X tinyauth/internal/constants.Version=${VERSION} -X tinyauth/internal/constants.CommitHash=${COMMIT_HASH} -X tinyauth/internal/constants.BuildTimestamp=${BUILD_TIMESTAMP}"
# Runner
FROM alpine:3.21 AS runner
@@ -48,7 +52,4 @@ COPY --from=builder /tinyauth/tinyauth ./
EXPOSE 3000
HEALTHCHECK --interval=10s --timeout=5s \
CMD curl -f http://localhost:3000/api/healthcheck || exit 1
ENTRYPOINT ["./tinyauth"]

View File

@@ -1,5 +1,5 @@
<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>
<p>The easiest way to secure your apps with a login screen.</p>
</div>
@@ -7,7 +7,6 @@
<div align="center">
<img alt="License" src="https://img.shields.io/github/license/steveiliop56/tinyauth">
<img alt="Release" src="https://img.shields.io/github/v/release/steveiliop56/tinyauth">
<img alt="Commit activity" src="https://img.shields.io/github/commit-activity/w/steveiliop56/tinyauth">
<img alt="Issues" src="https://img.shields.io/github/issues/steveiliop56/tinyauth">
<img alt="Tinyauth CI" src="https://github.com/steveiliop56/tinyauth/actions/workflows/ci.yml/badge.svg">
<a title="Crowdin" target="_blank" href="https://crowdin.com/project/tinyauth"><img src="https://badges.crowdin.net/tinyauth/localized.svg"></a>
@@ -15,27 +14,31 @@
<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.
![Login](assets/screenshot.png)
![Screenshot](assets/screenshot.png)
> [!WARNING]
> Tinyauth is in active development and configuration may change often. Please make sure to carefully read the release notes before updating.
> [!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).
## 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!
> 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.
## 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.
## 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
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
@@ -53,7 +56,7 @@ Tinyauth is licensed under the GNU General Public License v3.0. TL;DR — You ma
Thanks a lot to the following people for providing me with more coffee:
<!-- sponsors --><a href="https://github.com/nicotsx"><img src="https:&#x2F;&#x2F;github.com&#x2F;nicotsx.png" width="64px" alt="User avatar: nicotsx" /></a>&nbsp;&nbsp;<a href="https://github.com/SimpleHomelab"><img src="https:&#x2F;&#x2F;github.com&#x2F;SimpleHomelab.png" width="64px" alt="User avatar: SimpleHomelab" /></a>&nbsp;&nbsp;<a href="https://github.com/jmadden91"><img src="https:&#x2F;&#x2F;github.com&#x2F;jmadden91.png" width="64px" alt="User avatar: jmadden91" /></a>&nbsp;&nbsp;<a href="https://github.com/tribor"><img src="https:&#x2F;&#x2F;github.com&#x2F;tribor.png" width="64px" alt="User avatar: tribor" /></a>&nbsp;&nbsp;<!-- sponsors -->
<!-- sponsors --><a href="https://github.com/erwinkramer"><img src="https:&#x2F;&#x2F;github.com&#x2F;erwinkramer.png" width="64px" alt="User avatar: erwinkramer" /></a>&nbsp;&nbsp;<a href="https://github.com/nicotsx"><img src="https:&#x2F;&#x2F;github.com&#x2F;nicotsx.png" width="64px" alt="User avatar: nicotsx" /></a>&nbsp;&nbsp;<a href="https://github.com/SimpleHomelab"><img src="https:&#x2F;&#x2F;github.com&#x2F;SimpleHomelab.png" width="64px" alt="User avatar: SimpleHomelab" /></a>&nbsp;&nbsp;<a href="https://github.com/jmadden91"><img src="https:&#x2F;&#x2F;github.com&#x2F;jmadden91.png" width="64px" alt="User avatar: jmadden91" /></a>&nbsp;&nbsp;<a href="https://github.com/tribor"><img src="https:&#x2F;&#x2F;github.com&#x2F;tribor.png" width="64px" alt="User avatar: tribor" /></a>&nbsp;&nbsp;<a href="https://github.com/eliasbenb"><img src="https:&#x2F;&#x2F;github.com&#x2F;eliasbenb.png" width="64px" alt="User avatar: eliasbenb" /></a>&nbsp;&nbsp;<!-- sponsors -->
## Acknowledgements
@@ -61,6 +64,8 @@ Credits for the logo of this app go to:
- **Freepik** for providing the police hat and badge.
- **Renee French** for the original gopher logo.
- **Coderabbit AI** for providing free AI code reviews.
- **Kurt Cotoaga** for providing the bacgkround image of the app.
## Star History

BIN
assets/logo-rounded.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

BIN
assets/logo-solid.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 3.0 MiB

View File

@@ -8,8 +8,8 @@ import (
totpCmd "tinyauth/cmd/totp"
userCmd "tinyauth/cmd/user"
"tinyauth/internal/api"
"tinyauth/internal/assets"
"tinyauth/internal/auth"
"tinyauth/internal/constants"
"tinyauth/internal/docker"
"tinyauth/internal/handlers"
"tinyauth/internal/hooks"
@@ -50,7 +50,7 @@ var rootCmd = &cobra.Command{
// Logger
log.Logger = log.Level(zerolog.Level(config.LogLevel))
log.Info().Str("version", assets.Version).Msg("Starting tinyauth")
log.Info().Str("version", strings.TrimSpace(constants.Version)).Msg("Starting tinyauth")
// Users
log.Info().Msg("Parsing users")
@@ -79,6 +79,7 @@ var rootCmd = &cobra.Command{
GenericAuthURL: config.GenericAuthURL,
GenericTokenURL: config.GenericTokenURL,
GenericUserURL: config.GenericUserURL,
GenericSkipSSL: config.GenericSkipSSL,
AppURL: config.AppURL,
}
@@ -91,6 +92,7 @@ var rootCmd = &cobra.Command{
CookieSecure: config.CookieSecure,
Domain: domain,
ForgotPasswordMessage: config.FogotPasswordMessage,
BackgroundImage: config.BackgroundImage,
OAuthAutoRedirect: config.OAuthAutoRedirect,
}
@@ -196,6 +198,7 @@ func init() {
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-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().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)")
@@ -205,6 +208,7 @@ func init() {
rootCmd.Flags().Int("log-level", 1, "Log level.")
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("background-image", "/background.jpg", "Background image URL for the login page.")
// Bind flags to environment
viper.BindEnv("port", "PORT")
@@ -229,6 +233,7 @@ func init() {
viper.BindEnv("generic-token-url", "GENERIC_TOKEN_URL")
viper.BindEnv("generic-user-url", "GENERIC_USER_URL")
viper.BindEnv("generic-name", "GENERIC_NAME")
viper.BindEnv("generic-skip-ssl", "GENERIC_SKIP_SSL")
viper.BindEnv("disable-continue", "DISABLE_CONTINUE")
viper.BindEnv("oauth-whitelist", "OAUTH_WHITELIST")
viper.BindEnv("oauth-auto-redirect", "OAUTH_AUTO_REDIRECT")
@@ -238,6 +243,7 @@ func init() {
viper.BindEnv("login-timeout", "LOGIN_TIMEOUT")
viper.BindEnv("login-max-retries", "LOGIN_MAX_RETRIES")
viper.BindEnv("forgot-password-message", "FORGOT_PASSWORD_MESSAGE")
viper.BindEnv("background-image", "BACKGROUND_IMAGE")
// Bind flags to viper
viper.BindPFlags(rootCmd.Flags())

24
cmd/version.go Normal file
View File

@@ -0,0 +1,24 @@
package cmd
import (
"fmt"
"tinyauth/internal/constants"
"github.com/spf13/cobra"
)
// Create the version command
var versionCmd = &cobra.Command{
Use: "version",
Short: "Print the version number of Tinyauth",
Long: `All software has versions. This is Tinyauth's`,
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("Version: %s\n", constants.Version)
fmt.Printf("Commit Hash: %s\n", constants.CommitHash)
fmt.Printf("Build Timestamp: %s\n", constants.BuildTimestamp)
},
}
func init() {
rootCmd.AddCommand(versionCmd)
}

View File

@@ -1,3 +1,6 @@
# Ignore artifacts:
dist
node_modules
node_modules
bun.lock
package.json
src/lib/i18n/locales

View File

@@ -3,7 +3,7 @@ FROM oven/bun:1.1.45-alpine
WORKDIR /frontend
COPY ./frontend/package.json ./
COPY ./frontend/bun.lockb ./
COPY ./frontend/bun.lock ./
RUN bun install
@@ -16,7 +16,6 @@ COPY ./frontend/tsconfig.json ./
COPY ./frontend/tsconfig.app.json ./
COPY ./frontend/tsconfig.node.json ./
COPY ./frontend/vite.config.ts ./
COPY ./frontend/postcss.config.cjs ./
EXPOSE 5173

1093
frontend/bun.lock Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

21
frontend/components.json Normal file
View 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"
}

View File

@@ -3,6 +3,7 @@ import globals from "globals";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import tseslint from "typescript-eslint";
import pluginQuery from "@tanstack/eslint-plugin-query";
export default tseslint.config(
{ ignores: ["dist"] },
@@ -16,6 +17,7 @@ export default tseslint.config(
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
"@tanstack/query": pluginQuery,
},
rules: {
...reactHooks.configs.recommended.rules,
@@ -23,6 +25,7 @@ export default tseslint.config(
"warn",
{ allowConstantExport: true },
],
"@tanstack/query/exhaustive-deps": "error",
},
},
);

View File

@@ -3,13 +3,15 @@
<head>
<meta charset="UTF-8" />
<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="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="manifest" href="/frontend.webmanifest" />
<meta name="apple-mobile-web-app-title" content="Tinyauth" />
<link rel="manifest" href="/site.webmanifest" />
<title>Tinyauth</title>
</head>
<body>
<body class="dark">
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
{
"name": "frontend",
"name": "tinyauth-shadcn",
"private": true,
"version": "0.0.0",
"type": "module",
@@ -10,39 +10,49 @@
"preview": "vite preview"
},
"dependencies": {
"@mantine/core": "^7.16.0",
"@mantine/form": "^7.16.0",
"@mantine/hooks": "^7.16.0",
"@mantine/notifications": "^7.16.0",
"@tanstack/react-query": "5",
"axios": "^1.7.9",
"i18next": "^25.0.0",
"i18next-browser-languagedetector": "^8.0.4",
"i18next-chained-backend": "^4.6.2",
"i18next-http-backend": "^3.0.2",
"@hookform/resolvers": "^5.0.1",
"@radix-ui/react-label": "^2.1.6",
"@radix-ui/react-select": "^2.2.2",
"@radix-ui/react-separator": "^1.1.4",
"@radix-ui/react-slot": "^1.2.2",
"@tailwindcss/vite": "^4.1.4",
"@tanstack/react-query": "^5.75.6",
"axios": "^1.9.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dompurify": "^3.2.5",
"i18next": "^25.0.2",
"i18next-browser-languagedetector": "^8.0.5",
"i18next-resources-to-backend": "^1.2.1",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-i18next": "^15.4.1",
"input-otp": "^1.4.2",
"lucide-react": "^0.503.0",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.56.3",
"react-i18next": "^15.5.1",
"react-markdown": "^10.1.0",
"react-router": "^7.5.2",
"zod": "^3.24.1"
"react-router": "^7.5.3",
"sonner": "^2.0.3",
"tailwind-merge": "^3.2.0",
"tailwindcss": "^4.1.4",
"zod": "^3.24.4"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@types/react": "^19.1.1",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react-swc": "^3.5.0",
"eslint": "^9.17.0",
"eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.16",
"@eslint/js": "^9.22.0",
"@tanstack/eslint-plugin-query": "^5.74.7",
"@types/node": "^22.15.3",
"@types/react": "^19.0.10",
"@types/react-dom": "^19.0.4",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.22.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.19",
"globals": "^16.0.0",
"postcss": "^8.5.1",
"postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "3.5.3",
"typescript": "~5.8.3",
"typescript-eslint": "^8.18.2",
"vite": "^6.3.4"
"tw-animate-css": "^1.2.8",
"typescript": "~5.7.2",
"typescript-eslint": "^8.26.1",
"vite": "^6.3.1"
}
}

View File

@@ -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",
},
},
},
};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 588 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 602 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 48 KiB

View File

@@ -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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -1,17 +1,12 @@
import { Navigate } from "react-router";
import { useUserContext } from "./context/user-context";
import { LogoutPage } from "./pages/logout-page";
export const App = () => {
const queryString = window.location.search;
const params = new URLSearchParams(queryString);
const redirectUri = params.get("redirect_uri");
const { isLoggedIn } = useUserContext();
if (!isLoggedIn) {
return <Navigate to={`/login?redirect_uri=${redirectUri}`} />;
if (isLoggedIn) {
return <Navigate to="/logout" />;
}
return <LogoutPage />;
return <Navigate to="/login" />;
};

View 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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -1,40 +1,54 @@
import { Button, PinInput } from "@mantine/core";
import { useForm, zodResolver } from "@mantine/form";
import { z } from "zod";
import { Form, FormControl, FormField, FormItem } from "../ui/form";
import {
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({
code: z.string(),
});
type FormValues = z.infer<typeof schema>;
interface TotpFormProps {
onSubmit: (values: FormValues) => void;
isPending: boolean;
interface Props {
formId: string;
onSubmit: (code: TotpSchema) => void;
loading?: boolean;
}
export const TotpForm = (props: TotpFormProps) => {
const { onSubmit, isPending } = props;
export const TotpForm = (props: Props) => {
const { formId, onSubmit, loading } = props;
const form = useForm({
mode: "uncontrolled",
initialValues: {
code: "",
},
validate: zodResolver(schema),
const form = useForm<TotpSchema>({
resolver: zodResolver(totpSchema),
});
return (
<form onSubmit={form.onSubmit(onSubmit)}>
<PinInput
length={6}
type={"number"}
placeholder=""
{...form.getInputProps("code")}
/>
<Button type="submit" mt="xl" loading={isPending} fullWidth>
Verify
</Button>
</form>
<Form {...form}>
<form id={formId} onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="code"
render={({ field }) => (
<FormItem>
<FormControl>
<InputOTP maxLength={6} disabled={loading} {...field}>
<InputOTPGroup>
<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>
);
};

View File

@@ -1,6 +1,6 @@
import type { SVGProps } from "react";
export function OAuthIcon(props: SVGProps<SVGSVGElement>) {
export function GenericIcon(props: SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"

View 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>
);
}

View File

@@ -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}
/>
);
};

View 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>
);
};

View File

@@ -0,0 +1,20 @@
import { useAppContext } from "@/context/app-context";
import { LanguageSelector } from "../language/language";
export const Layout = ({ children }: { children: React.ReactNode }) => {
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 />
{children}
</div>
);
};

View File

@@ -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>
</>
);
};

View 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 };

View 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,
};

View 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,
};

View 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 };

View 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 };

View 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 };

View 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>
);
};

View 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,
};

View 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 };

View 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 };

View File

@@ -1,40 +1,42 @@
import {
appContextSchema,
AppContextSchema,
} from "@/schemas/app-context-schema";
import { createContext, useContext } from "react";
import { useSuspenseQuery } from "@tanstack/react-query";
import React, { createContext, useContext } from "react";
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 = ({
children,
}: {
children: React.ReactNode;
}) => {
const {
data: userContext,
isLoading,
error,
} = useSuspenseQuery({
queryKey: ["appContext"],
queryFn: async () => {
const res = await axios.get("/api/app");
return res.data;
},
const { isFetching, data, error } = useSuspenseQuery({
queryKey: ["app"],
queryFn: () => axios.get("/api/app").then((res) => res.data),
});
if (error && !isLoading) {
if (error && !isFetching) {
throw error;
}
const validated = appContextSchema.safeParse(data);
if (validated.success === false) {
throw validated.error;
}
return (
<AppContext.Provider value={userContext}>{children}</AppContext.Provider>
<AppContext.Provider value={validated.data}>{children}</AppContext.Provider>
);
};
export const useAppContext = () => {
const context = useContext(AppContext);
if (context === null) {
if (!context) {
throw new Error("useAppContext must be used within an AppContextProvider");
}

View File

@@ -1,41 +1,47 @@
import {
userContextSchema,
UserContextSchema,
} from "@/schemas/user-context-schema";
import { createContext, useContext } from "react";
import { useSuspenseQuery } from "@tanstack/react-query";
import React, { createContext, useContext } from "react";
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 = ({
children,
}: {
children: React.ReactNode;
}) => {
const {
data: userContext,
isLoading,
error,
} = useSuspenseQuery({
queryKey: ["userContext"],
queryFn: async () => {
const res = await axios.get("/api/user");
return res.data;
},
const { isFetching, data, error } = useSuspenseQuery({
queryKey: ["user"],
queryFn: () => axios.get("/api/user").then((res) => res.data),
});
if (error && !isLoading) {
if (error && !isFetching) {
throw error;
}
const validated = userContextSchema.safeParse(data);
if (validated.success === false) {
throw validated.error;
}
return (
<UserContext.Provider value={userContext}>{children}</UserContext.Provider>
<UserContext.Provider value={validated.data}>
{children}
</UserContext.Provider>
);
};
export const useUserContext = () => {
const context = useContext(UserContext);
if (context === null) {
throw new Error("useUserContext must be used within a UserContextProvider");
if (!context) {
throw new Error(
"useUserContext must be used within an UserContextProvider",
);
}
return context;

View File

@@ -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>
);
}

View File

@@ -1,4 +1,176 @@
span,
p {
word-break: break-word;
@import "tailwindcss";
@import "tw-animate-css";
@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-7 [&: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;
}

View File

@@ -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 {
const isMounted = useRef(false)
const isMounted = useRef(false);
useEffect(() => {
isMounted.current = true
isMounted.current = true;
return () => {
isMounted.current = false
}
}, [])
isMounted.current = false;
};
}, []);
return useCallback(() => isMounted.current, [])
}
return useCallback(() => isMounted.current, []);
}

View File

@@ -1,28 +1,16 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
import LanguageDetector from "i18next-browser-languagedetector";
import ChainedBackend from "i18next-chained-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
.use(ChainedBackend)
.use(LanguageDetector)
.use(initReactI18next)
.use(
resourcesToBackend(
(language: string) => import(`./locales/${language}.json`),
),
)
.init({
fallbackLng: "en",
debug: import.meta.env.MODE === "development",
@@ -32,11 +20,6 @@ i18n
},
load: "currentOnly",
backend: {
backends: import.meta.env.MODE !== "development" ? backends : backends.reverse(),
backendOptions: import.meta.env.MODE !== "development" ? backendOptions : backendOptions.reverse()
},
});
export default i18n;

View File

@@ -1,36 +1,37 @@
export const languages = {
"af-ZA": "Afrikaans",
"ar-SA": "العربية",
"ca-ES": "Català",
"cs-CZ": "Čeština",
"da-DK": "Dansk",
"de-DE": "Deutsch",
"el-GR": "Ελληνικά",
"en-US": "English",
"es-ES": "Español",
"fi-FI": "Suomi",
"fr-FR": "Français",
"he-IL": "עברית",
"hu-HU": "Magyar",
"it-IT": "Italiano",
"ja-JP": "日本語",
"ko-KR": "한국어",
"nl-NL": "Nederlands",
"no-NO": "Norsk",
"pl-PL": "Polski",
"pt-BR": "Português",
"pt-PT": "Português",
"ro-RO": "Română",
"ru-RU": "Русский",
"sr-SP": "Српски",
"sv-SE": "Svenska",
"tr-TR": "Türkçe",
"uk-UA": "Українська",
"vi-VN": "Tiếng Việt",
"zh-CN": "中文",
"zh-TW": "中文"
}
"af-ZA": "Afrikaans",
"ar-SA": "العربية",
"ca-ES": "Català",
"cs-CZ": "Čeština",
"da-DK": "Dansk",
"de-DE": "Deutsch",
"el-GR": "Ελληνικά",
"en-US": "English",
"es-ES": "Español",
"fi-FI": "Suomi",
"fr-FR": "Français",
"he-IL": "עברית",
"hu-HU": "Magyar",
"it-IT": "Italiano",
"ja-JP": "日本語",
"ko-KR": "한국어",
"nl-NL": "Nederlands",
"no-NO": "Norsk",
"pl-PL": "Polski",
"pt-BR": "Português",
"pt-PT": "Português",
"ro-RO": "Română",
"ru-RU": "Русский",
"sr-SP": "Српски",
"sv-SE": "Svenska",
"tr-TR": "Türkçe",
"uk-UA": "Українська",
"vi-VN": "Tiếng Việt",
"zh-CN": "中文",
"zh-TW": "中文",
};
export type SupportedLanguage = keyof typeof languages;
export const getLanguageName = (language: SupportedLanguage): string => languages[language];
export const getLanguageName = (language: SupportedLanguage): string =>
languages[language];

View File

@@ -1,15 +1,16 @@
{
"loginTitle": "Welcome back, login with",
"loginDivider": "Or continue with password",
"loginTitleSimple": "Welcome back, please login",
"loginDivider": "Or",
"loginUsername": "Username",
"loginPassword": "Password",
"loginSubmit": "Login",
"loginFailTitle": "Failed to log in",
"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",
"loginSuccessSubtitle": "Welcome back!",
"loginOauthFailTitle": "Internal error",
"loginOauthFailTitle": "An error occurred",
"loginOauthFailSubtitle": "Failed to get OAuth URL",
"loginOauthSuccessTitle": "Redirecting",
"loginOauthSuccessSubtitle": "Redirecting to your OAuth provider",
@@ -18,19 +19,16 @@
"continueInvalidRedirectTitle": "Invalid redirect",
"continueInvalidRedirectSubtitle": "The redirect URL is invalid",
"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",
"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",
"logoutFailSubtitle": "Please try again",
"logoutSuccessTitle": "Logged out",
"logoutSuccessSubtitle": "You have been logged out",
"logoutTitle": "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.",
"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.",
"notFoundTitle": "Page not found",
"notFoundSubtitle": "The page you are looking for does not exist.",
"notFoundButton": "Go home",
@@ -39,13 +37,17 @@
"totpSuccessTitle": "Verified",
"totpSuccessSubtitle": "Redirecting to your app",
"totpTitle": "Enter your TOTP code",
"totpSubtitle": "Please enter the code from your authenticator app.",
"unauthorizedTitle": "Unauthorized",
"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.",
"unauthorizedGroupsSubtitle": "The user with username <Code>{{username}}</Code> is not in the groups required by 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.",
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.",
"unauthorizedButton": "Try again",
"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",
"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."
}

View File

@@ -1,15 +1,16 @@
{
"loginTitle": "Welcome back, login with",
"loginDivider": "Or continue with password",
"loginTitleSimple": "Welcome back, please login",
"loginDivider": "Or",
"loginUsername": "Username",
"loginPassword": "Password",
"loginSubmit": "Login",
"loginFailTitle": "Failed to log in",
"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",
"loginSuccessSubtitle": "Welcome back!",
"loginOauthFailTitle": "Internal error",
"loginOauthFailTitle": "An error occurred",
"loginOauthFailSubtitle": "Failed to get OAuth URL",
"loginOauthSuccessTitle": "Redirecting",
"loginOauthSuccessSubtitle": "Redirecting to your OAuth provider",
@@ -18,19 +19,16 @@
"continueInvalidRedirectTitle": "Invalid redirect",
"continueInvalidRedirectSubtitle": "The redirect URL is invalid",
"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",
"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",
"logoutFailSubtitle": "Please try again",
"logoutSuccessTitle": "Logged out",
"logoutSuccessSubtitle": "You have been logged out",
"logoutTitle": "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.",
"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.",
"notFoundTitle": "Page not found",
"notFoundSubtitle": "The page you are looking for does not exist.",
"notFoundButton": "Go home",
@@ -39,13 +37,17 @@
"totpSuccessTitle": "Verified",
"totpSuccessSubtitle": "Redirecting to your app",
"totpTitle": "Enter your TOTP code",
"totpSubtitle": "Please enter the code from your authenticator app.",
"unauthorizedTitle": "Unauthorized",
"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.",
"unauthorizedGroupsSubtitle": "The user with username <Code>{{username}}</Code> is not in the groups required by 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.",
"unauthorizedGroupsSubtitle": "The user with username <code>{{username}}</code> is not in the groups required by the resource <code>{{resource}}</code>.",
"unauthorizedButton": "Try again",
"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",
"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
View 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);
};

View File

@@ -1,54 +1,83 @@
import { StrictMode } from "react";
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 { Layout } from "./components/layout/layout.tsx";
import { createBrowserRouter, RouterProvider } 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 router = createBrowserRouter([
{
path: "/",
element: <App />,
errorElement: <ErrorPage />,
},
{
path: "/login",
element: <LoginPage />,
errorElement: <ErrorPage />,
},
{
path: "/logout",
element: <LogoutPage />,
errorElement: <ErrorPage />,
},
{
path: "/continue",
element: <ContinuePage />,
errorElement: <ErrorPage />,
},
{
path: "/totp",
element: <TotpPage />,
errorElement: <ErrorPage />,
},
{
path: "/forgot-password",
element: <ForgotPasswordPage />,
errorElement: <ErrorPage />,
},
{
path: "/unauthorized",
element: <UnauthorizedPage />,
errorElement: <ErrorPage />,
},
{
path: "/error",
element: <ErrorPage />,
errorElement: <ErrorPage />,
},
{
path: "*",
element: <NotFoundPage />,
errorElement: <ErrorPage />,
},
]);
const queryClient = new QueryClient();
createRoot(document.getElementById("root")!).render(
<StrictMode>
<MantineProvider defaultColorScheme="auto">
<QueryClientProvider client={queryClient}>
<Notifications />
<AppContextProvider>
<UserContextProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<App />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/totp" element={<TotpPage />} />
<Route path="/logout" element={<LogoutPage />} />
<Route path="/continue" element={<ContinuePage />} />
<Route path="/unauthorized" element={<UnauthorizedPage />} />
<Route path="/error" element={<InternalServerError />} />
<Route
path="/forgot-password"
element={<ForgotPasswordPage />}
/>
<Route path="*" element={<NotFoundPage />} />
</Routes>
</BrowserRouter>
</UserContextProvider>
</AppContextProvider>
</QueryClientProvider>
</MantineProvider>
<QueryClientProvider client={queryClient}>
<AppContextProvider>
<UserContextProvider>
<Layout>
<RouterProvider router={router} />
<Toaster />
</Layout>
</UserContextProvider>
</AppContextProvider>
</QueryClientProvider>
</StrictMode>,
);

View File

@@ -1,144 +1,132 @@
import { Button, Code, Paper, Text } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { Navigate } from "react-router";
import { useUserContext } from "../context/user-context";
import { Layout } from "../components/layouts/layout";
import { ReactNode } from "react";
import { escapeRegex, isValidRedirectUri } from "../utils/utils";
import { useAppContext } from "../context/app-context";
import { Button } from "@/components/ui/button";
import {
Card,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} 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 { Navigate, useLocation, useNavigate } from "react-router";
import DOMPurify from "dompurify";
export const ContinuePage = () => {
const queryString = window.location.search;
const params = new URLSearchParams(queryString);
const redirectUri = params.get("redirect_uri") ?? "";
const { isLoggedIn } = useUserContext();
const { disableContinue, domain } = useAppContext();
const { t } = useTranslation();
if (!isLoggedIn) {
return <Navigate to={`/login?redirect_uri=${redirectUri}`} />;
return <Navigate to="/login" />;
}
if (!isValidRedirectUri(redirectUri)) {
return <Navigate to="/" />;
const { domain, disableContinue } = useAppContext();
const { search } = useLocation();
const searchParams = new URLSearchParams(search);
const redirectURI = searchParams.get("redirect_uri");
if (!redirectURI) {
return <Navigate to="/logout" />;
}
const redirect = () => {
notifications.show({
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)}$`);
if (!regex.test(uri.hostname)) {
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 (!isValidUrl(DOMPurify.sanitize(redirectURI))) {
return <Navigate to="/logout" />;
}
if (disableContinue) {
window.location.href = redirectUri;
window.location.href = DOMPurify.sanitize(redirectURI);
}
const { t } = useTranslation();
const navigate = useNavigate();
const url = new URL(redirectURI);
if (!(url.hostname == domain) && !url.hostname.endsWith(`.${domain}`)) {
return (
<ContinuePageLayout>
<Text size="xl" fw={700}>
{t("continueRedirectingTitle")}
</Text>
<Text>{t("continueRedirectingSubtitle")}</Text>
</ContinuePageLayout>
<Card className="min-w-xs sm:min-w-sm">
<CardHeader>
<CardTitle className="text-3xl">
{t("untrustedRedirectTitle")}
</CardTitle>
<CardDescription>
<Trans
i18nKey="untrustedRedirectSubtitle"
t={t}
components={{
code: <code />,
}}
values={{ domain }}
/>
</CardDescription>
</CardHeader>
<CardFooter className="flex flex-col items-stretch gap-2">
<Button
onClick={() =>
(window.location.href = DOMPurify.sanitize(redirectURI))
}
variant="destructive"
>
{t("continueTitle")}
</Button>
<Button onClick={() => navigate("/logout")} variant="outline">
{t("cancelTitle")}
</Button>
</CardFooter>
</Card>
);
}
if (window.location.protocol === "https:" && uri.protocol === "http:") {
if (url.protocol === "http:" && window.location.protocol === "https:") {
return (
<ContinuePageLayout>
<Text size="xl" fw={700}>
{t("continueInsecureRedirectTitle")}
</Text>
<Text>
<Trans
i18nKey="continueInsecureRedirectSubtitle"
t={t}
components={{ Code: <Code /> }}
/>
</Text>
<Button fullWidth mt="xl" color="yellow" onClick={redirect}>
<Card className="min-w-xs sm:min-w-sm">
<CardHeader>
<CardTitle className="text-3xl">
{t("continueInsecureRedirectTitle")}
</CardTitle>
<CardDescription>
<Trans
i18nKey="continueInsecureRedirectSubtitle"
t={t}
components={{
code: <code />,
}}
/>
</CardDescription>
</CardHeader>
<CardFooter className="flex flex-col items-stretch gap-2">
<Button
onClick={() =>
(window.location.href = DOMPurify.sanitize(redirectURI))
}
variant="warning"
>
{t("continueTitle")}
</Button>
<Button onClick={() => navigate("/logout")} variant="outline">
{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={() =>
(window.location.href = DOMPurify.sanitize(redirectURI))
}
>
{t("continueTitle")}
</Button>
<Button
fullWidth
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>
</CardFooter>
</Card>
);
};

View 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>
);
};

View File

@@ -1,25 +1,25 @@
import { Paper, Text, TypographyStylesProvider } from "@mantine/core";
import { Layout } from "../components/layouts/layout";
import {
Card,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useAppContext } from "@/context/app-context";
import { useTranslation } from "react-i18next";
import { useAppContext } from "../context/app-context";
import Markdown from 'react-markdown'
import Markdown from "react-markdown";
export const ForgotPasswordPage = () => {
const { t } = useTranslation();
const { forgotPasswordMessage } = useAppContext();
const { t } = useTranslation();
return (
<Layout>
<Paper shadow="md" p={30} mt={30} radius="md" withBorder>
<Text size="xl" fw={700}>
{t("forgotPasswordTitle")}
</Text>
<TypographyStylesProvider>
<Markdown>
{forgotPasswordMessage}
</Markdown>
</TypographyStylesProvider>
</Paper>
</Layout>
<Card className="min-w-xs sm:min-w-sm">
<CardHeader>
<CardTitle className="text-3xl">{t("forgotPasswordTitle")}</CardTitle>
<CardDescription>
<Markdown>{forgotPasswordMessage}</Markdown>
</CardDescription>
</CardHeader>
</Card>
);
};

View File

@@ -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>
);
};

View File

@@ -1,181 +1,166 @@
import { Paper, Title, Text, Divider } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { LoginForm } from "@/components/auth/login-form";
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 axios, { type AxiosError } from "axios";
import { useUserContext } from "../context/user-context";
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 axios, { AxiosError } from "axios";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useEffect, useState } from "react";
import { useIsMounted } from "../lib/hooks/use-is-mounted";
import { isValidRedirectUri } from "../utils/utils";
import { Navigate, useLocation } from "react-router";
import { toast } from "sonner";
export const LoginPage = () => {
const queryString = window.location.search;
const params = new URLSearchParams(queryString);
const redirectUri = params.get("redirect_uri") ?? "";
const { isLoggedIn } = useUserContext();
if (isLoggedIn) {
return <Navigate to="/logout" />;
}
const {
configuredProviders,
title,
genericName,
oauthAutoRedirect: oauthAutoRedirectContext,
} = useAppContext();
const { configuredProviders, title, oauthAutoRedirect, genericName } = useAppContext();
const { search } = useLocation();
const { t } = useTranslation();
const [oauthAutoRedirect, setOAuthAutoRedirect] = useState(
oauthAutoRedirectContext,
);
const oauthProviders = configuredProviders.filter(
(value) => value !== "username",
);
const isMounted = useIsMounted();
const loginMutation = useMutation({
mutationFn: (login: LoginFormValues) => {
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;
}
const searchParams = new URLSearchParams(search);
const redirectUri = searchParams.get("redirect_uri");
notifications.show({
title: t("loginSuccessTitle"),
message: t("loginSuccessSubtitle"),
color: "green",
});
const oauthConfigured =
configuredProviders.filter((provider) => provider !== "username").length >
0;
const userAuthConfigured = configuredProviders.includes("username");
setTimeout(() => {
if (!isValidRedirectUri(redirectUri)) {
window.location.replace("/");
return;
}
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");
},
const oauthMutation = useMutation({
mutationFn: (provider: string) =>
axios.get(
`/api/oauth/url/${provider}?redirect_uri=${encodeURIComponent(redirectUri ?? "")}`,
),
mutationKey: ["oauth"],
onSuccess: (data) => {
notifications.show({
title: t("loginOauthSuccessTitle"),
message: t("loginOauthSuccessSubtitle"),
color: "blue",
toast.info(t("loginOauthSuccessTitle"), {
description: t("loginOauthSuccessSubtitle"),
});
setTimeout(() => {
window.location.href = data.data.url;
}, 500);
},
onError: () => {
toast.error(t("loginOauthFailTitle"), {
description: t("loginOauthFailSubtitle"),
});
},
});
const handleSubmit = (values: LoginFormValues) => {
loginMutation.mutate(values);
};
const loginMutation = useMutation({
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(() => {
if (isMounted()) {
if (
oauthProviders.includes(oauthAutoRedirect) &&
isValidRedirectUri(redirectUri)
oauthConfigured &&
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 (
<Layout>
<Title ta="center">{title}</Title>
<Paper shadow="md" p="xl" mt={30} radius="md" withBorder>
{oauthProviders.length > 0 && (
<>
<Text size="lg" fw={500} ta="center">
{t("loginTitle")}
</Text>
<OAuthButtons
oauthProviders={oauthProviders}
isPending={loginOAuthMutation.isPending}
mutate={loginOAuthMutation.mutate}
genericName={genericName}
/>
{configuredProviders.includes("username") && (
<Divider
label={t("loginDivider")}
labelPosition="center"
my="lg"
<Card className="min-w-xs sm:min-w-sm">
<CardHeader>
<CardTitle className="text-center text-3xl">{title}</CardTitle>
{configuredProviders.length > 0 && (
<CardDescription className="text-center">
{oauthConfigured ? t("loginTitle") : t("loginTitleSimple")}
</CardDescription>
)}
</CardHeader>
<CardContent className="flex flex-col gap-4">
{oauthConfigured && (
<div className="flex flex-col gap-2 items-center justify-center">
{configuredProviders.includes("google") && (
<OAuthButton
title="Google"
icon={<GoogleIcon />}
className="w-full"
onClick={() => oauthMutation.mutate("google")}
/>
)}
</>
{configuredProviders.includes("github") && (
<OAuthButton
title="Github"
icon={<GithubIcon />}
className="w-full"
onClick={() => oauthMutation.mutate("github")}
/>
)}
{configuredProviders.includes("generic") && (
<OAuthButton
title={genericName}
icon={<GenericIcon />}
className="w-full"
onClick={() => oauthMutation.mutate("generic")}
/>
)}
</div>
)}
{configuredProviders.includes("username") && (
{userAuthConfigured && oauthConfigured && (
<SeperatorWithChildren>{t("loginDivider")}</SeperatorWithChildren>
)}
{userAuthConfigured && (
<LoginForm
isPending={loginMutation.isPending}
onSubmit={handleSubmit}
onSubmit={(values) => loginMutation.mutate(values)}
loading={loginMutation.isPending}
/>
)}
</Paper>
</Layout>
{configuredProviders.length == 0 && (
<h3 className="text-center text-xl text-red-600">
{t("failedToFetchProvidersTitle")}
</h3>
)}
</CardContent>
</Card>
);
};

View File

@@ -1,84 +1,89 @@
import { Button, Code, Paper, Text } from "@mantine/core";
import { notifications } from "@mantine/notifications";
import { Button } from "@/components/ui/button";
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 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 { Navigate } from "react-router";
import { toast } from "sonner";
export const LogoutPage = () => {
const { isLoggedIn, oauth, provider, email, username } = useUserContext();
const { genericName } = useAppContext();
const { t } = useTranslation();
const { provider, username, isLoggedIn, email } = useUserContext();
if (!isLoggedIn) {
return <Navigate to="/login" />;
}
const { genericName } = useAppContext();
const { t } = useTranslation();
const logoutMutation = useMutation({
mutationFn: () => {
return axios.post("/api/logout");
},
onError: () => {
notifications.show({
title: t("logoutFailTitle"),
message: t("logoutFailSubtitle"),
color: "red",
});
},
mutationFn: () => axios.post("/api/logout"),
mutationKey: ["logout"],
onSuccess: () => {
notifications.show({
title: t("logoutSuccessTitle"),
message: t("logoutSuccessSubtitle"),
color: "green",
toast.success(t("logoutSuccessTitle"), {
description: t("logoutSuccessSubtitle"),
});
setTimeout(() => {
setTimeout(async () => {
window.location.replace("/login");
}, 500);
},
onError: () => {
toast.error(t("logoutFailTitle"), {
description: t("logoutFailSubtitle"),
});
},
});
return (
<Layout>
<Paper shadow="md" p={30} mt={30} radius="md" withBorder>
<Text size="xl" fw={700}>
{t("logoutTitle")}
</Text>
<Text>
{oauth ? (
<Card className="min-w-xs sm:min-w-sm">
<CardHeader>
<CardTitle className="text-3xl">{t("logoutTitle")}</CardTitle>
<CardDescription>
{provider !== "username" ? (
<Trans
i18nKey="logoutOauthSubtitle"
t={t}
components={{ Code: <Code /> }}
components={{
code: <code />,
}}
values={{
username: email,
provider:
provider === "generic" ? genericName : capitalize(provider),
username: email,
}}
/>
) : (
<Trans
i18nKey="logoutUsernameSubtitle"
t={t}
components={{ Code: <Code /> }}
components={{
code: <code />,
}}
values={{
username: username,
username,
}}
/>
)}
</Text>
</CardDescription>
</CardHeader>
<CardFooter className="flex flex-col items-stretch">
<Button
fullWidth
mt="xl"
onClick={() => logoutMutation.mutate()}
loading={logoutMutation.isPending}
onClick={() => logoutMutation.mutate()}
>
{t("logoutTitle")}
</Button>
</Paper>
</Layout>
</CardFooter>
</Card>
);
};

View File

@@ -1,20 +1,27 @@
import { Button, Paper, Text } from "@mantine/core";
import { Layout } from "../components/layouts/layout";
import { Button } from "@/components/ui/button";
import {
Card,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
export const NotFoundPage = () => {
const { t } = useTranslation();
const navigate = useNavigate();
return (
<Layout>
<Paper shadow="md" p={30} mt={30} radius="md" withBorder>
<Text size="xl" fw={700}>
{t("notFoundTitle")}
</Text>
<Text>{t("notFoundSubtitle")}</Text>
<Button fullWidth mt="xl" onClick={() => window.location.replace("/")}>
{t("notFoundButton")}
</Button>
</Paper>
</Layout>
<Card className="min-w-xs sm:min-w-sm">
<CardHeader>
<CardTitle className="text-3xl">{t("notFoundTitle")}</CardTitle>
<CardDescription>{t("notFoundSubtitle")}</CardDescription>
</CardHeader>
<CardFooter className="flex flex-col items-stretch">
<Button onClick={() => navigate("/")}>{t("notFoundButton")}</Button>
</CardFooter>
</Card>
);
};

View File

@@ -1,66 +1,69 @@
import { Navigate } from "react-router";
import { useUserContext } from "../context/user-context";
import { Title, Paper, Text } from "@mantine/core";
import { Layout } from "../components/layouts/layout";
import { TotpForm } from "../components/auth/totp-form";
import { TotpForm } from "@/components/auth/totp-form";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { TotpSchema } from "@/schemas/totp-schema";
import { useMutation } from "@tanstack/react-query";
import axios from "axios";
import { notifications } from "@mantine/notifications";
import { useAppContext } from "../context/app-context";
import { useId } from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router";
import { toast } from "sonner";
export const TotpPage = () => {
const queryString = window.location.search;
const params = new URLSearchParams(queryString);
const redirectUri = params.get("redirect_uri") ?? "";
const { totpPending, isLoggedIn } = useUserContext();
const { title } = useAppContext();
const { t } = useTranslation();
const { search } = useLocation();
const navigate = useNavigate();
const formId = useId();
if (isLoggedIn) {
return <Navigate to={`/logout`} />;
}
if (!totpPending) {
return <Navigate to={`/login?redirect_uri=${redirectUri}`} />;
}
const searchParams = new URLSearchParams(search);
const redirectUri = searchParams.get("redirect_uri");
const totpMutation = useMutation({
mutationFn: async (totp: { code: string }) => {
await axios.post("/api/totp", totp);
mutationFn: (values: TotpSchema) => axios.post("/api/totp", values),
mutationKey: ["totp"],
onSuccess: () => {
toast.success(t("totpSuccessTitle"), {
description: t("totpSuccessSubtitle"),
});
setTimeout(() => {
navigate(
`/continue?redirect_uri=${encodeURIComponent(redirectUri ?? "")}`,
);
}, 500);
},
onError: () => {
notifications.show({
title: t("totpFailTitle"),
message: t("totpFailSubtitle"),
color: "red",
toast.error(t("totpFailTitle"), {
description: t("totpFailSubtitle"),
});
},
onSuccess: () => {
notifications.show({
title: t("totpSuccessTitle"),
message: t("totpSuccessSubtitle"),
color: "green",
});
setTimeout(() => {
window.location.replace(`/continue?redirect_uri=${redirectUri}`);
}, 500);
},
});
return (
<Layout>
<Title ta="center">{title}</Title>
<Paper shadow="md" p="xl" mt={30} radius="md" withBorder>
<Text size="lg" fw={500} mb="md" ta="center">
{t("totpTitle")}
</Text>
<Card className="min-w-xs sm:min-w-sm">
<CardHeader>
<CardTitle className="text-3xl">{t("totpTitle")}</CardTitle>
<CardDescription>{t("totpSubtitle")}</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center">
<TotpForm
isPending={totpMutation.isPending}
formId={formId}
onSubmit={(values) => totpMutation.mutate(values)}
loading={totpMutation.isPending}
/>
</Paper>
</Layout>
</CardContent>
<CardFooter className="flex flex-col items-stretch">
<Button form={formId} type="submit" loading={totpMutation.isPending}>
{t("continueTitle")}
</Button>
</CardFooter>
</Card>
);
};

View File

@@ -1,98 +1,62 @@
import { Button, Code, Paper, Text } from "@mantine/core";
import { Layout } from "../components/layouts/layout";
import { Navigate } from "react-router";
import { Button } from "@/components/ui/button";
import {
Card,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Trans, useTranslation } from "react-i18next";
import React, { useEffect } from "react";
import { isValidQuery } from "../utils/utils";
import { useIsMounted } from "../lib/hooks/use-is-mounted";
import { Navigate, useLocation, useNavigate } from "react-router";
export const UnauthorizedPage = () => {
const queryString = window.location.search;
const params = new URLSearchParams(queryString);
const username = params.get("username") ?? "";
const groupErr = params.get("groupErr") ?? "";
const resource = params.get("resource") ?? "";
const { search } = useLocation();
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();
useEffect(() => {
if (useMounted()) {
if (isValidQuery(groupErr)) {
if (groupErr === "true") {
setIsGroupErr(true);
return;
}
setIsGroupErr(false);
return;
}
setIsGroupErr(false);
}
}, []);
const { t } = useTranslation();
if (!isValidQuery(username)) {
if (!username) {
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 navigate = useNavigate();
let i18nKey = "unauthorizedLoginSubtitle";
if (resource) {
i18nKey = "unauthorizedResourceSubtitle";
}
if (groupErr === "true") {
i18nKey = "unauthorizedGroupsSubtitle";
}
return (
<Layout>
<Paper shadow="md" p={30} mt={30} radius="md" withBorder>
<Text size="xl" fw={700}>
{t("Unauthorized")}
</Text>
<Text>{children}</Text>
<Button
fullWidth
mt="xl"
onClick={() => window.location.replace("/login")}
>
<Card className="min-w-xs sm:min-w-sm">
<CardHeader>
<CardTitle className="text-3xl">{t("unauthorizedTitle")}</CardTitle>
<CardDescription>
<Trans
i18nKey={i18nKey}
t={t}
components={{
code: <code />,
}}
values={{
username,
resource,
}}
/>
</CardDescription>
</CardHeader>
<CardFooter className="flex flex-col items-stretch">
<Button onClick={() => navigate("/login")}>
{t("unauthorizedButton")}
</Button>
</Paper>
</Layout>
</CardFooter>
</Card>
);
};

View File

@@ -8,6 +8,7 @@ export const appContextSchema = z.object({
domain: z.string(),
forgotPasswordMessage: z.string(),
oauthAutoRedirect: z.enum(["none", "github", "google", "generic"]),
backgroundImage: z.string(),
});
export type AppContextSchemaType = z.infer<typeof appContextSchema>;
export type AppContextSchema = z.infer<typeof appContextSchema>;

View File

@@ -5,4 +5,4 @@ export const loginSchema = z.object({
password: z.string(),
});
export type LoginFormValues = z.infer<typeof loginSchema>;
export type LoginSchema = z.infer<typeof loginSchema>;

View File

@@ -0,0 +1,7 @@
import { z } from "zod";
export const totpSchema = z.object({
code: z.string(),
});
export type TotpSchema = z.infer<typeof totpSchema>;

View File

@@ -5,9 +5,9 @@ export const userContextSchema = z.object({
username: z.string(),
name: z.string(),
email: z.string(),
oauth: z.boolean(),
provider: z.string(),
oauth: z.boolean(),
totpPending: z.boolean(),
});
export type UserContextSchemaType = z.infer<typeof userContextSchema>;
export type UserContextSchema = z.infer<typeof userContextSchema>;

View File

@@ -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;
}

View File

@@ -1,5 +1,11 @@
{
"compilerOptions": {
// Resolve paths
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2020",
"useDefineForClassFields": true,

View File

@@ -3,5 +3,11 @@
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
],
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

@@ -1,9 +1,16 @@
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/
export default defineConfig({
plugins: [react()],
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
server: {
host: "0.0.0.0",
proxy: {
@@ -12,6 +19,7 @@ export default defineConfig({
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ""),
},
}
}
},
allowedHosts: true,
},
});

14
go.mod
View File

@@ -10,7 +10,7 @@ require (
github.com/rs/zerolog v1.34.0
github.com/spf13/cobra v1.9.1
github.com/spf13/viper v1.20.1
golang.org/x/crypto v0.37.0
golang.org/x/crypto v0.38.0
)
require (
@@ -25,7 +25,7 @@ require (
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.34.0 // indirect
go.opentelemetry.io/otel/sdk v1.34.0 // indirect
golang.org/x/term v0.31.0 // indirect
golang.org/x/term v0.32.0 // indirect
gotest.tools/v3 v3.5.2 // indirect
rsc.io/qr v0.2.0 // indirect
)
@@ -85,7 +85,7 @@ require (
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pquerna/otp v1.4.0
github.com/pquerna/otp v1.5.0
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
@@ -103,10 +103,10 @@ require (
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.13.0 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/oauth2 v0.29.0
golang.org/x/sync v0.13.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.24.0 // indirect
golang.org/x/oauth2 v0.30.0
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
google.golang.org/protobuf v1.36.3 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

28
go.sum
View File

@@ -190,8 +190,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pquerna/otp v1.4.0 h1:wZvl1TIVxKRThZIBiwOOHOGP/1+nZyWBil9Y2XNEDzg=
github.com/pquerna/otp v1.4.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
@@ -269,8 +269,8 @@ golang.org/x/arch v0.13.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
@@ -281,13 +281,13 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98=
golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -297,14 +297,14 @@ golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View File

@@ -8,8 +8,3 @@ import (
//
//go:embed dist
var Assets embed.FS
// Version file
//
//go:embed version
var Version string

View File

@@ -1 +0,0 @@
v3.3.0

View File

@@ -16,3 +16,8 @@ type Claims struct {
PreferredUsername string `json:"preferred_username"`
Groups []string `json:"groups"`
}
// Version information
var Version = "development"
var CommitHash = "n/a"
var BuildTimestamp = "n/a"

View File

@@ -29,8 +29,13 @@ func (docker *Docker) Init() error {
return err
}
// Set the context and api client
// Create the context
docker.Context = context.Background()
// Negotiate API version
client.NegotiateAPIVersion(docker.Context)
// Set client
docker.Client = client
// Done

View File

@@ -147,9 +147,6 @@ func (h *Handlers) AuthHandler(c *gin.Context) {
if !appAllowed {
log.Warn().Str("username", userContext.Username).Str("host", host).Msg("User not allowed")
// Set WWW-Authenticate header
c.Header("WWW-Authenticate", "Basic realm=\"tinyauth\"")
if proxy.Proxy == "nginx" || !isBrowser {
c.JSON(401, gin.H{
"status": 401,
@@ -181,7 +178,7 @@ func (h *Handlers) AuthHandler(c *gin.Context) {
}
// 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
}
@@ -196,9 +193,6 @@ func (h *Handlers) AuthHandler(c *gin.Context) {
if !groupOk {
log.Warn().Str("username", userContext.Username).Str("host", host).Msg("User is not in required groups")
// Set WWW-Authenticate header
c.Header("WWW-Authenticate", "Basic realm=\"tinyauth\"")
if proxy.Proxy == "nginx" || !isBrowser {
c.JSON(401, gin.H{
"status": 401,
@@ -231,7 +225,7 @@ func (h *Handlers) AuthHandler(c *gin.Context) {
}
// 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
}
}
@@ -258,9 +252,6 @@ func (h *Handlers) AuthHandler(c *gin.Context) {
// The user is not logged in
log.Debug().Msg("Unauthorized")
// Set www-authenticate header
c.Header("WWW-Authenticate", "Basic realm=\"tinyauth\"")
if proxy.Proxy == "nginx" || !isBrowser {
c.JSON(401, gin.H{
"status": 401,
@@ -282,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")
// 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) {
@@ -509,6 +500,7 @@ func (h *Handlers) AppHandler(c *gin.Context) {
GenericName: h.Config.GenericName,
Domain: h.Config.Domain,
ForgotPasswordMessage: h.Config.ForgotPasswordMessage,
BackgroundImage: h.Config.BackgroundImage,
OAuthAutoRedirect: h.Config.OAuthAutoRedirect,
}
@@ -534,10 +526,9 @@ func (h *Handlers) UserHandler(c *gin.Context) {
TotpPending: userContext.TotpPending,
}
// If we are not logged in we set the status to 401 and add the WWW-Authenticate header else we set it to 200
// If we are not logged in we set the status to 401 else we set it to 200
if !userContext.IsLoggedIn {
log.Debug().Msg("Unauthorized")
c.Header("WWW-Authenticate", "Basic realm=\"tinyauth\"")
userContextResponse.Message = "Unauthorized"
} else {
log.Debug().Interface("userContext", userContext).Msg("Authenticated")

View File

@@ -110,8 +110,8 @@ func (hooks *Hooks) UseUserContext(c *gin.Context) types.UserContext {
log.Debug().Msg("Provider exists")
// Check if the oauth email is whitelisted
if !hooks.Auth.EmailWhitelisted(cookie.Username) {
log.Error().Str("email", cookie.Username).Msg("Email is not whitelisted")
if !hooks.Auth.EmailWhitelisted(cookie.Email) {
log.Error().Str("email", cookie.Email).Msg("Email is not whitelisted")
// It isn't so we delete the cookie and return an empty context
hooks.Auth.DeleteSessionCookie(c)

View File

@@ -3,28 +3,48 @@ package oauth
import (
"context"
"crypto/rand"
"crypto/tls"
"encoding/base64"
"net/http"
"golang.org/x/oauth2"
)
func NewOAuth(config oauth2.Config) *OAuth {
func NewOAuth(config oauth2.Config, insecureSkipVerify bool) *OAuth {
return &OAuth{
Config: config,
Config: config,
InsecureSkipVerify: insecureSkipVerify,
}
}
type OAuth struct {
Config oauth2.Config
Context context.Context
Token *oauth2.Token
Verifier string
Config oauth2.Config
Context context.Context
Token *oauth2.Token
Verifier string
InsecureSkipVerify bool
}
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()
// 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()
}

View File

@@ -103,6 +103,7 @@ func GetGithubUser(client *http.Client) (constants.Claims, error) {
for _, email := range emails {
if email.Primary {
// Set the email then exit
log.Debug().Str("email", email.Email).Msg("Found primary email")
user.Email = email.Email
break
}
@@ -115,6 +116,7 @@ func GetGithubUser(client *http.Client) (constants.Claims, error) {
// Set the email if it is not set picking the first one
if user.Email == "" {
log.Warn().Str("email", emails[0].Email).Msg("No primary email found, using first email")
user.Email = emails[0].Email
}

View File

@@ -36,7 +36,7 @@ func (providers *Providers) Init() {
RedirectURL: fmt.Sprintf("%s/api/oauth/callback/github", providers.Config.AppURL),
Scopes: GithubScopes(),
Endpoint: endpoints.GitHub,
})
}, false)
// Initialize the oauth provider
providers.Github.Init()
@@ -53,7 +53,7 @@ func (providers *Providers) Init() {
RedirectURL: fmt.Sprintf("%s/api/oauth/callback/google", providers.Config.AppURL),
Scopes: GoogleScopes(),
Endpoint: endpoints.Google,
})
}, false)
// Initialize the oauth provider
providers.Google.Init()
@@ -73,7 +73,7 @@ func (providers *Providers) Init() {
AuthURL: providers.Config.GenericAuthURL,
TokenURL: providers.Config.GenericTokenURL,
},
})
}, providers.Config.GenericSkipSSL)
// Initialize the oauth provider
providers.Generic.Init()

Some files were not shown because too many files have changed in this diff Show More