mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-03-28 18:26:36 +00:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edfb99d221 | ||
|
|
282ff82b0c | ||
|
|
9d5f83da78 | ||
|
|
896da812a3 | ||
|
|
d2b3b7647d | ||
|
|
025378d14e | ||
|
|
e033ba6d45 | ||
|
|
e09562824a | ||
|
|
08f7fd16a9 | ||
|
|
be45eed125 | ||
|
|
9e94a436cc | ||
|
|
f82020ccfb | ||
|
|
a4a90a16a9 | ||
|
|
365734ec5d | ||
|
|
d02d8931a0 | ||
|
|
24c948e6a6 | ||
|
|
7a54d3ae20 | ||
|
|
5e1d19e0a4 | ||
|
|
d6a9bb4c09 | ||
|
|
3c67765992 | ||
|
|
6bb613e0e7 | ||
|
|
7be115f7da | ||
|
|
924bb1468b | ||
|
|
4553458939 | ||
|
|
9c2848db1d | ||
|
|
64cf56276a |
@@ -23,6 +23,9 @@ jobs:
|
|||||||
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
|
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
|
||||||
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
|
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
|
||||||
|
|
||||||
|
- name: Download GeoLite2 City database
|
||||||
|
run: MAXMIND_LICENSE_KEY=${{ secrets.MAXMIND_LICENSE_KEY }} sh scripts/download-ip-database.sh
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v4
|
uses: docker/build-push-action@v4
|
||||||
with:
|
with:
|
||||||
|
|||||||
3
.github/workflows/e2e-tests.yml
vendored
3
.github/workflows/e2e-tests.yml
vendored
@@ -16,6 +16,9 @@ jobs:
|
|||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: frontend/package-lock.json
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
- name: Create dummy GeoLite2 City database
|
||||||
|
run: touch ./backend/GeoLite2-City.mmdb
|
||||||
|
|
||||||
- name: Build Docker Image
|
- name: Build Docker Image
|
||||||
run: docker build -t stonith404/pocket-id .
|
run: docker build -t stonith404/pocket-id .
|
||||||
- name: Run Docker Container
|
- name: Run Docker Container
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -34,4 +34,5 @@ vite.config.ts.timestamp-*
|
|||||||
# Application specific
|
# Application specific
|
||||||
data
|
data
|
||||||
/frontend/tests/.auth
|
/frontend/tests/.auth
|
||||||
pocket-id-backend
|
pocket-id-backend
|
||||||
|
/backend/GeoLite2-City.mmdb
|
||||||
73
CHANGELOG.md
73
CHANGELOG.md
@@ -1,3 +1,76 @@
|
|||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.8.0...v) (2024-10-11)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add key id to JWK ([282ff82](https://github.com/stonith404/pocket-id/commit/282ff82b0c7e2414b3528c8ca325758245b8ae61))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.7.1...v) (2024-10-04)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add location based on ip to the audit log ([025378d](https://github.com/stonith404/pocket-id/commit/025378d14edd2d72da76e90799a0ccdd42cf672c))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.7.0...v) (2024-10-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* initials don't get displayed if Gravatar avatar doesn't exist ([e095628](https://github.com/stonith404/pocket-id/commit/e09562824a794bc7d240e9d229709d4b389db7d5))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.6.0...v) (2024-10-03)
|
||||||
|
|
||||||
|
|
||||||
|
### ⚠ BREAKING CHANGES
|
||||||
|
|
||||||
|
* add ability to set light and dark mode logo
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add ability to set light and dark mode logo ([be45eed](https://github.com/stonith404/pocket-id/commit/be45eed125e33e9930572660a034d5f12dc310ce))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.5.3...v) (2024-10-02)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add copy to clipboard option for OIDC client information ([f82020c](https://github.com/stonith404/pocket-id/commit/f82020ccfb0d4fbaa1dd98182188149d8085252a))
|
||||||
|
* add gravatar profile picture integration ([365734e](https://github.com/stonith404/pocket-id/commit/365734ec5d8966c2ab877c60cfb176b9cdc36880))
|
||||||
|
* add user groups ([24c948e](https://github.com/stonith404/pocket-id/commit/24c948e6a66f283866f6c8369c16fa6cbcfa626c))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* only return user groups if it is explicitly requested ([a4a90a1](https://github.com/stonith404/pocket-id/commit/a4a90a16a9726569a22e42560184319b25fd7ca6))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.5.2...v) (2024-09-26)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add space to "Firstname" and "Lastname" label ([#31](https://github.com/stonith404/pocket-id/issues/31)) ([d6a9bb4](https://github.com/stonith404/pocket-id/commit/d6a9bb4c09efb8102da172e49c36c070b341f0fc))
|
||||||
|
* port environment variables get ignored in caddyfile ([3c67765](https://github.com/stonith404/pocket-id/commit/3c67765992d7369a79812bc8cd216c9ba12fd96e))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.5.1...v) (2024-09-19)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* updated application name doesn't apply to webauthn credential ([924bb14](https://github.com/stonith404/pocket-id/commit/924bb1468bbd8e42fa6a530ef740be73ce3b3914))
|
||||||
|
|
||||||
|
## [](https://github.com/stonith404/pocket-id/compare/v0.5.0...v) (2024-09-16)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* **email:** improve email templating ([#27](https://github.com/stonith404/pocket-id/issues/27)) ([64cf562](https://github.com/stonith404/pocket-id/commit/64cf56276a07169bc601a11be905c1eea67c4750))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* debounce oidc client and user search ([9c2848d](https://github.com/stonith404/pocket-id/commit/9c2848db1d93c230afc6c5f64e498e9f6df8c8a7))
|
||||||
|
|
||||||
## [](https://github.com/stonith404/pocket-id/compare/v0.4.1...v) (2024-09-09)
|
## [](https://github.com/stonith404/pocket-id/compare/v0.4.1...v) (2024-09-09)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ COPY --from=frontend-builder /app/frontend/package.json ./frontend/package.json
|
|||||||
|
|
||||||
COPY --from=backend-builder /app/backend/pocket-id-backend ./backend/pocket-id-backend
|
COPY --from=backend-builder /app/backend/pocket-id-backend ./backend/pocket-id-backend
|
||||||
COPY --from=backend-builder /app/backend/migrations ./backend/migrations
|
COPY --from=backend-builder /app/backend/migrations ./backend/migrations
|
||||||
|
COPY --from=backend-builder /app/backend/GeoLite2-City.mmdb ./backend/GeoLite2-City.mmdb
|
||||||
COPY --from=backend-builder /app/backend/email-templates ./backend/email-templates
|
COPY --from=backend-builder /app/backend/email-templates ./backend/email-templates
|
||||||
COPY --from=backend-builder /app/backend/images ./backend/images
|
COPY --from=backend-builder /app/backend/images ./backend/images
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ Required tools:
|
|||||||
cd ..
|
cd ..
|
||||||
pm2 start pocket-id-backend --name pocket-id-backend
|
pm2 start pocket-id-backend --name pocket-id-backend
|
||||||
|
|
||||||
|
# Optional: Download the GeoLite2 city database.
|
||||||
|
# If not downloaded the ip location in the audit log will be empty.
|
||||||
|
MAXMIND_LICENSE_KEY=<your-key> sh scripts/download-ip-database.sh
|
||||||
|
|
||||||
# Start the frontend
|
# Start the frontend
|
||||||
cd ../frontend
|
cd ../frontend
|
||||||
npm install
|
npm install
|
||||||
@@ -94,7 +98,7 @@ You may need the following information:
|
|||||||
- **Userinfo URL**: `https://<your-domain>/api/oidc/userinfo`
|
- **Userinfo URL**: `https://<your-domain>/api/oidc/userinfo`
|
||||||
- **Certificate URL**: `https://<your-domain>/.well-known/jwks.json`
|
- **Certificate URL**: `https://<your-domain>/.well-known/jwks.json`
|
||||||
- **OIDC Discovery URL**: `https://<your-domain>/.well-known/openid-configuration`
|
- **OIDC Discovery URL**: `https://<your-domain>/.well-known/openid-configuration`
|
||||||
- **PKCE**: `false` as this is not supported yet.
|
- **Scopes**: At least `openid email`. Optionally you can add `profile` and `groups`.
|
||||||
|
|
||||||
### Proxy Services with Pocket ID
|
### Proxy Services with Pocket ID
|
||||||
|
|
||||||
@@ -131,6 +135,9 @@ docker compose up -d
|
|||||||
cd ..
|
cd ..
|
||||||
pm2 start pocket-id-backend --name pocket-id-backend
|
pm2 start pocket-id-backend --name pocket-id-backend
|
||||||
|
|
||||||
|
# Optional: Update the GeoLite2 city database
|
||||||
|
MAXMIND_LICENSE_KEY=<your-key> sh scripts/download-ip-database.sh
|
||||||
|
|
||||||
# Start the frontend
|
# Start the frontend
|
||||||
cd ../frontend
|
cd ../frontend
|
||||||
npm install
|
npm install
|
||||||
|
|||||||
14
backend/email-templates/components/email_html.tmpl
Normal file
14
backend/email-templates/components/email_html.tmpl
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{{ define "root" }}
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
{{ template "style" . }}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
{{ template "base" . }}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{ end }}
|
||||||
7
backend/email-templates/components/email_text.tmpl
Normal file
7
backend/email-templates/components/email_text.tmpl
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{{- define "root" -}}
|
||||||
|
{{- template "base" . -}}
|
||||||
|
{{- end }}
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
This is automatically sent email from {{.AppName}}.
|
||||||
80
backend/email-templates/components/style_html.tmpl
Normal file
80
backend/email-templates/components/style_html.tmpl
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
{{ define "style" }}
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
color: #333;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
background-color: #fff;
|
||||||
|
color: #333;
|
||||||
|
padding: 32px;
|
||||||
|
border-radius: 10px;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 40px auto;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
.header .logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.header .logo img {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
.header h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.warning {
|
||||||
|
background-color: #ffd966;
|
||||||
|
color: #7f6000;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 50px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
background-color: #fafafa;
|
||||||
|
color: #333;
|
||||||
|
padding: 24px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.content h2 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.grid div {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.grid p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.message {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{{ end }}
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
background-color: #f0f0f0;
|
|
||||||
color: #333;
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.container {
|
|
||||||
background-color: #fff;
|
|
||||||
color: #333;
|
|
||||||
padding: 32px;
|
|
||||||
border-radius: 10px;
|
|
||||||
max-width: 600px;
|
|
||||||
margin: 40px auto;
|
|
||||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
.header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
|
||||||
.header .logo {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.header .logo img {
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
.header h1 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
.warning {
|
|
||||||
background-color: #ffd966;
|
|
||||||
color: #7f6000;
|
|
||||||
padding: 4px 12px;
|
|
||||||
border-radius: 50px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
.content {
|
|
||||||
background-color: #fafafa;
|
|
||||||
color: #333;
|
|
||||||
padding: 24px;
|
|
||||||
border-radius: 10px;
|
|
||||||
}
|
|
||||||
.content h2 {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
.grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
.grid div {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.grid p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
.label {
|
|
||||||
color: #888;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
.message {
|
|
||||||
font-size: 1rem;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
<title>Pocket ID</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
|
|
||||||
<div class="container">
|
|
||||||
<div class="header">
|
|
||||||
<div class="logo">
|
|
||||||
<img src="{{appUrl}}/api/application-configuration/logo" alt="Pocket ID" />
|
|
||||||
<h1>{{appName}}</h1>
|
|
||||||
</div>
|
|
||||||
<div class="warning">Warning</div>
|
|
||||||
</div>
|
|
||||||
<div class="content">
|
|
||||||
<h2>New Sign-In Detected</h2>
|
|
||||||
<div class="grid">
|
|
||||||
<div>
|
|
||||||
<p class="label">IP Address</p>
|
|
||||||
<p>{{ipAddress}}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="label">Device</p>
|
|
||||||
<p>{{device}}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p class="label">Sign-In Time</p>
|
|
||||||
<p>{{dateTimeString}}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="message">
|
|
||||||
This sign-in was detected from a new device or location. If you recognize this activity, you can safely ignore
|
|
||||||
this message. If not, please review your account and security settings.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
36
backend/email-templates/login-with-new-device_html.tmpl
Normal file
36
backend/email-templates/login-with-new-device_html.tmpl
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
{{ define "base" }}
|
||||||
|
<div class="header">
|
||||||
|
<div class="logo">
|
||||||
|
<img src="{{ .LogoURL }}" alt="Pocket ID"/>
|
||||||
|
<h1>{{ .AppName }}</h1>
|
||||||
|
</div>
|
||||||
|
<div class="warning">Warning</div>
|
||||||
|
</div>
|
||||||
|
<div class="content">
|
||||||
|
<h2>New Sign-In Detected</h2>
|
||||||
|
<div class="grid">
|
||||||
|
{{ if and .Data.City .Data.Country }}
|
||||||
|
<div>
|
||||||
|
<p class="label">Approximate Location</p>
|
||||||
|
<p>{{ .Data.City }}, {{ .Data.Country }}</p>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
<div>
|
||||||
|
<p class="label">IP Address</p>
|
||||||
|
<p>{{ .Data.IPAddress }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="label">Device</p>
|
||||||
|
<p>{{ .Data.Device }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="label">Sign-In Time</p>
|
||||||
|
<p>{{ .Data.DateTime.Format "2006-01-02 15:04:05 UTC" }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="message">
|
||||||
|
This sign-in was detected from a new device or location. If you recognize this activity, you can
|
||||||
|
safely ignore this message. If not, please review your account and security settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{ end -}}
|
||||||
15
backend/email-templates/login-with-new-device_text.tmpl
Normal file
15
backend/email-templates/login-with-new-device_text.tmpl
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{{ define "base" -}}
|
||||||
|
New Sign-In Detected
|
||||||
|
====================
|
||||||
|
|
||||||
|
{{ if and .Data.City .Data.Country }}
|
||||||
|
Approximate Location: {{ .Data.City }}, {{ .Data.Country }}
|
||||||
|
{{ end }}
|
||||||
|
IP Address: {{ .Data.IPAddress }}
|
||||||
|
Device: {{ .Data.Device }}
|
||||||
|
Time: {{ .Data.DateTime.Format "2006-01-02 15:04:05 UTC"}}
|
||||||
|
|
||||||
|
This sign-in was detected from a new device or location. If you recognize
|
||||||
|
this activity, you can safely ignore this message. If not, please review
|
||||||
|
your account and security settings.
|
||||||
|
{{ end -}}
|
||||||
@@ -1,28 +1,29 @@
|
|||||||
module github.com/stonith404/pocket-id/backend
|
module github.com/stonith404/pocket-id/backend
|
||||||
|
|
||||||
go 1.23
|
go 1.23.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/caarlos0/env/v11 v11.2.2
|
github.com/caarlos0/env/v11 v11.2.2
|
||||||
github.com/fxamacker/cbor/v2 v2.7.0
|
github.com/fxamacker/cbor/v2 v2.7.0
|
||||||
github.com/gin-contrib/cors v1.7.2
|
github.com/gin-contrib/cors v1.7.2
|
||||||
github.com/gin-gonic/gin v1.10.0
|
github.com/gin-gonic/gin v1.10.0
|
||||||
github.com/go-co-op/gocron/v2 v2.11.0
|
github.com/go-co-op/gocron/v2 v2.12.1
|
||||||
github.com/go-playground/validator/v10 v10.22.0
|
github.com/go-playground/validator/v10 v10.22.1
|
||||||
github.com/go-webauthn/webauthn v0.11.1
|
github.com/go-webauthn/webauthn v0.11.2
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1
|
github.com/golang-jwt/jwt/v5 v5.2.1
|
||||||
github.com/golang-migrate/migrate/v4 v4.17.1
|
github.com/golang-migrate/migrate/v4 v4.18.1
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/mileusna/useragent v1.3.4
|
github.com/mileusna/useragent v1.3.5
|
||||||
golang.org/x/crypto v0.26.0
|
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.1
|
||||||
|
golang.org/x/crypto v0.27.0
|
||||||
golang.org/x/time v0.6.0
|
golang.org/x/time v0.6.0
|
||||||
gorm.io/driver/sqlite v1.5.6
|
gorm.io/driver/sqlite v1.5.6
|
||||||
gorm.io/gorm v1.25.11
|
gorm.io/gorm v1.25.12
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bytedance/sonic v1.12.1 // indirect
|
github.com/bytedance/sonic v1.12.3 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.2.0 // indirect
|
github.com/bytedance/sonic/loader v0.2.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||||
@@ -30,7 +31,7 @@ require (
|
|||||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-webauthn/x v0.1.12 // indirect
|
github.com/go-webauthn/x v0.1.14 // indirect
|
||||||
github.com/goccy/go-json v0.10.3 // indirect
|
github.com/goccy/go-json v0.10.3 // indirect
|
||||||
github.com/google/go-tpm v0.9.1 // indirect
|
github.com/google/go-tpm v0.9.1 // indirect
|
||||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||||
@@ -43,22 +44,21 @@ require (
|
|||||||
github.com/kr/pretty v0.3.1 // indirect
|
github.com/kr/pretty v0.3.1 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
github.com/mattn/go-sqlite3 v1.14.23 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||||
github.com/rogpeppe/go-internal v1.11.0 // indirect
|
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
go.uber.org/atomic v1.11.0 // indirect
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
golang.org/x/arch v0.9.0 // indirect
|
golang.org/x/arch v0.10.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
|
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect
|
||||||
golang.org/x/net v0.27.0 // indirect
|
golang.org/x/net v0.29.0 // indirect
|
||||||
golang.org/x/sys v0.23.0 // indirect
|
golang.org/x/sys v0.25.0 // indirect
|
||||||
golang.org/x/text v0.17.0 // indirect
|
golang.org/x/text v0.18.0 // indirect
|
||||||
google.golang.org/protobuf v1.34.2 // indirect
|
google.golang.org/protobuf v1.34.2 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
github.com/bytedance/sonic v1.12.1 h1:jWl5Qz1fy7X1ioY74WqO0KjAMtAGQs4sYnjiEBiyX24=
|
github.com/bytedance/sonic v1.12.3 h1:W2MGa7RCU1QTeYRTPE3+88mVC0yXmsRQRChiyVocVjU=
|
||||||
github.com/bytedance/sonic v1.12.1/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
|
github.com/bytedance/sonic v1.12.3/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk=
|
||||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||||
github.com/bytedance/sonic/loader v0.2.0 h1:zNprn+lsIP06C/IqCHs3gPQIvnvpKbbxyXQP1iU4kWM=
|
github.com/bytedance/sonic/loader v0.2.0 h1:zNprn+lsIP06C/IqCHs3gPQIvnvpKbbxyXQP1iU4kWM=
|
||||||
github.com/bytedance/sonic/loader v0.2.0/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
github.com/bytedance/sonic/loader v0.2.0/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||||
@@ -23,26 +23,26 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
|
|||||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||||
github.com/go-co-op/gocron/v2 v2.11.0 h1:IOowNA6SzwdRFnD4/Ol3Kj6G2xKfsoiiGq2Jhhm9bvE=
|
github.com/go-co-op/gocron/v2 v2.12.1 h1:dCIIBFbzhWKdgXeEifBjHPzgQ1hoWhjS4289Hjjy1uw=
|
||||||
github.com/go-co-op/gocron/v2 v2.11.0/go.mod h1:xY7bJxGazKam1cz04EebrlP4S9q4iWdiAylMGP3jY9w=
|
github.com/go-co-op/gocron/v2 v2.12.1/go.mod h1:xY7bJxGazKam1cz04EebrlP4S9q4iWdiAylMGP3jY9w=
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao=
|
github.com/go-playground/validator/v10 v10.22.1 h1:40JcKH+bBNGFczGuoBYgX4I6m/i27HYW8P9FDk5PbgA=
|
||||||
github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
github.com/go-playground/validator/v10 v10.22.1/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||||
github.com/go-webauthn/webauthn v0.11.1 h1:5G/+dg91/VcaJHTtJUfwIlNJkLwbJCcnUc4W8VtkpzA=
|
github.com/go-webauthn/webauthn v0.11.2 h1:Fgx0/wlmkClTKlnOsdOQ+K5HcHDsDcYIvtYmfhEOSUc=
|
||||||
github.com/go-webauthn/webauthn v0.11.1/go.mod h1:YXRm1WG0OtUyDFaVAgB5KG7kVqW+6dYCJ7FTQH4SxEE=
|
github.com/go-webauthn/webauthn v0.11.2/go.mod h1:aOtudaF94pM71g3jRwTYYwQTG1KyTILTcZqN1srkmD0=
|
||||||
github.com/go-webauthn/x v0.1.12 h1:RjQ5cvApzyU/xLCiP+rub0PE4HBZsLggbxGR5ZpUf/A=
|
github.com/go-webauthn/x v0.1.14 h1:1wrB8jzXAofojJPAaRxnZhRgagvLGnLjhCAwg3kTpT0=
|
||||||
github.com/go-webauthn/x v0.1.12/go.mod h1:XlRcGkNH8PT45TfeJYc6gqpOtiOendHhVmnOxh+5yHs=
|
github.com/go-webauthn/x v0.1.14/go.mod h1:UuVvFZ8/NbOnkDz3y1NaxtUN87pmtpC1PQ+/5BBQRdc=
|
||||||
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||||
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/golang-migrate/migrate/v4 v4.17.1 h1:4zQ6iqL6t6AiItphxJctQb3cFqWiSpMnX7wLTPnnYO4=
|
github.com/golang-migrate/migrate/v4 v4.18.1 h1:JML/k+t4tpHCpQTCAD62Nu43NUFzHY4CV3uAuvHGC+Y=
|
||||||
github.com/golang-migrate/migrate/v4 v4.17.1/go.mod h1:m8hinFyWBn0SA4QKHuKh175Pm9wjmxj3S2Mia7dbXzM=
|
github.com/golang-migrate/migrate/v4 v4.18.1/go.mod h1:HAX6m3sQgcdO81tdjn5exv20+3Kb13cmGli1hrD6hks=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/go-tpm v0.9.1 h1:0pGc4X//bAlmZzMKf8iz6IsDo1nYTbYJ6FZN/rg4zdM=
|
github.com/google/go-tpm v0.9.1 h1:0pGc4X//bAlmZzMKf8iz6IsDo1nYTbYJ6FZN/rg4zdM=
|
||||||
@@ -79,10 +79,10 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
|||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
|
||||||
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/mileusna/useragent v1.3.4 h1:MiuRRuvGjEie1+yZHO88UBYg8YBC/ddF6T7F56i3PCk=
|
github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
|
||||||
github.com/mileusna/useragent v1.3.4/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
|
github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
|
||||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@@ -90,26 +90,26 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
|||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.1 h1:UihPOz+oIJ5X0JsO7wEkL50fheCODsoZ9r86mJWfNMc=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.1/go.mod h1:vPpFrres6g9B5+meBwAd9xnp335KFcLEFW7EqJxBHy0=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
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/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
|
||||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
@@ -122,20 +122,20 @@ go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
|||||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
golang.org/x/arch v0.9.0 h1:ub9TgUInamJ8mrZIGlBG6/4TqWeMszd4N8lNorbrr6k=
|
golang.org/x/arch v0.10.0 h1:S3huipmSclq3PJMNe76NGwkBR504WFkQ5dhzWzP8ZW8=
|
||||||
golang.org/x/arch v0.9.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
golang.org/x/arch v0.10.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||||
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
|
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
|
||||||
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
|
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
|
||||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
|
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk=
|
||||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY=
|
||||||
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
|
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
|
||||||
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM=
|
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
|
||||||
golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
|
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
|
||||||
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||||
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
|
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
|
||||||
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||||
@@ -148,6 +148,6 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
|||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE=
|
gorm.io/driver/sqlite v1.5.6 h1:fO/X46qn5NUEEOZtnjJRWRzZMe8nqJiQ9E+0hi+hKQE=
|
||||||
gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
gorm.io/driver/sqlite v1.5.6/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
||||||
gorm.io/gorm v1.25.11 h1:/Wfyg1B/je1hnDx3sMkX+gAlxrlZpn6X0BXRlwXlvHg=
|
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||||
gorm.io/gorm v1.25.11/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||||
|
|||||||
3
backend/images/logoDark.svg
Normal file
3
backend/images/logoDark.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="a" viewBox="0 0 1015 1015">
|
||||||
|
<path fill="white" d="M506.6,0c209.52,0,379.98,170.45,379.98,379.96,0,82.33-25.9,160.68-74.91,226.54-48.04,64.59-113.78,111.51-190.13,135.71l-21.1,6.7-50.29-248.04,13.91-6.73c45.41-21.95,74.76-68.71,74.76-119.11,0-72.91-59.31-132.23-132.21-132.23s-132.23,59.32-132.23,132.23c0,50.4,29.36,97.16,74.77,119.11l13.65,6.61-81.01,499.24h-226.36V0h351.18Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 434 B |
3
backend/images/logoLight.svg
Normal file
3
backend/images/logoLight.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" id="a" viewBox="0 0 1015 1015">
|
||||||
|
<path fill="black" d="M506.6,0c209.52,0,379.98,170.45,379.98,379.96,0,82.33-25.9,160.68-74.91,226.54-48.04,64.59-113.78,111.51-190.13,135.71l-21.1,6.7-50.29-248.04,13.91-6.73c45.41-21.95,74.76-68.71,74.76-119.11,0-72.91-59.31-132.23-132.21-132.23s-132.23,59.32-132.23,132.23c0,50.4,29.36,97.16,74.77,119.11l13.65,6.61-81.01,499.24h-226.36V0h351.18Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 434 B |
@@ -5,24 +5,53 @@ import (
|
|||||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// initApplicationImages copies the images from the images directory to the application-images directory
|
||||||
func initApplicationImages() {
|
func initApplicationImages() {
|
||||||
dirPath := common.EnvConfig.UploadPath + "/application-images"
|
dirPath := common.EnvConfig.UploadPath + "/application-images"
|
||||||
|
|
||||||
files, err := os.ReadDir(dirPath)
|
sourceFiles, err := os.ReadDir("./images")
|
||||||
if err != nil && !os.IsNotExist(err) {
|
if err != nil && !os.IsNotExist(err) {
|
||||||
log.Fatalf("Error reading directory: %v", err)
|
log.Fatalf("Error reading directory: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip if files already exist
|
destinationFiles, err := os.ReadDir(dirPath)
|
||||||
if len(files) > 1 {
|
if err != nil && !os.IsNotExist(err) {
|
||||||
return
|
log.Fatalf("Error reading directory: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Copy files from source to destination
|
// Copy images from the images directory to the application-images directory if they don't already exist
|
||||||
err = utils.CopyDirectory("./images", dirPath)
|
for _, sourceFile := range sourceFiles {
|
||||||
if err != nil {
|
if sourceFile.IsDir() || imageAlreadyExists(sourceFile.Name(), destinationFiles) {
|
||||||
log.Fatalf("Error copying directory: %v", err)
|
continue
|
||||||
|
}
|
||||||
|
srcFilePath := "./images/" + sourceFile.Name()
|
||||||
|
destFilePath := dirPath + "/" + sourceFile.Name()
|
||||||
|
|
||||||
|
err := utils.CopyFile(srcFilePath, destFilePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Error copying file: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func imageAlreadyExists(fileName string, destinationFiles []os.DirEntry) bool {
|
||||||
|
for _, destinationFile := range destinationFiles {
|
||||||
|
sourceFileWithoutExtension := getImageNameWithoutExtension(fileName)
|
||||||
|
destinationFileWithoutExtension := getImageNameWithoutExtension(destinationFile.Name())
|
||||||
|
|
||||||
|
if sourceFileWithoutExtension == destinationFileWithoutExtension {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func getImageNameWithoutExtension(fileName string) string {
|
||||||
|
splitted := strings.Split(fileName, ".")
|
||||||
|
return strings.Join(splitted[:len(splitted)-1], ".")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package bootstrap
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -28,13 +29,19 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
|
|||||||
r.Use(gin.Logger())
|
r.Use(gin.Logger())
|
||||||
|
|
||||||
// Initialize services
|
// Initialize services
|
||||||
emailService := service.NewEmailService(appConfigService)
|
templateDir := os.DirFS(common.EnvConfig.EmailTemplatesPath)
|
||||||
|
emailService, err := service.NewEmailService(appConfigService, templateDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Unable to create email service: %s", err)
|
||||||
|
}
|
||||||
|
|
||||||
auditLogService := service.NewAuditLogService(db, appConfigService, emailService)
|
auditLogService := service.NewAuditLogService(db, appConfigService, emailService)
|
||||||
jwtService := service.NewJwtService(appConfigService)
|
jwtService := service.NewJwtService(appConfigService)
|
||||||
webauthnService := service.NewWebAuthnService(db, jwtService, auditLogService, appConfigService)
|
webauthnService := service.NewWebAuthnService(db, jwtService, auditLogService, appConfigService)
|
||||||
userService := service.NewUserService(db, jwtService)
|
userService := service.NewUserService(db, jwtService)
|
||||||
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService)
|
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService)
|
||||||
testService := service.NewTestService(db, appConfigService)
|
testService := service.NewTestService(db, appConfigService)
|
||||||
|
userGroupService := service.NewUserGroupService(db)
|
||||||
|
|
||||||
r.Use(middleware.NewCorsMiddleware().Add())
|
r.Use(middleware.NewCorsMiddleware().Add())
|
||||||
r.Use(middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60))
|
r.Use(middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60))
|
||||||
@@ -51,6 +58,7 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
|
|||||||
controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService)
|
controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService)
|
||||||
controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService)
|
controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService)
|
||||||
controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware)
|
controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware)
|
||||||
|
controller.NewUserGroupController(apiGroup, jwtAuthMiddleware, userGroupService)
|
||||||
|
|
||||||
// Add test controller in non-production environments
|
// Add test controller in non-production environments
|
||||||
if common.EnvConfig.AppEnv != "production" {
|
if common.EnvConfig.AppEnv != "production" {
|
||||||
|
|||||||
@@ -7,21 +7,23 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type EnvConfigSchema struct {
|
type EnvConfigSchema struct {
|
||||||
AppEnv string `env:"APP_ENV"`
|
AppEnv string `env:"APP_ENV"`
|
||||||
AppURL string `env:"PUBLIC_APP_URL"`
|
AppURL string `env:"PUBLIC_APP_URL"`
|
||||||
DBPath string `env:"DB_PATH"`
|
DBPath string `env:"DB_PATH"`
|
||||||
UploadPath string `env:"UPLOAD_PATH"`
|
UploadPath string `env:"UPLOAD_PATH"`
|
||||||
Port string `env:"BACKEND_PORT"`
|
Port string `env:"BACKEND_PORT"`
|
||||||
Host string `env:"HOST"`
|
Host string `env:"HOST"`
|
||||||
|
EmailTemplatesPath string `env:"EMAIL_TEMPLATES_PATH"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var EnvConfig = &EnvConfigSchema{
|
var EnvConfig = &EnvConfigSchema{
|
||||||
AppEnv: "production",
|
AppEnv: "production",
|
||||||
DBPath: "data/pocket-id.db",
|
DBPath: "data/pocket-id.db",
|
||||||
UploadPath: "data/uploads",
|
UploadPath: "data/uploads",
|
||||||
AppURL: "http://localhost",
|
AppURL: "http://localhost",
|
||||||
Port: "8080",
|
Port: "8080",
|
||||||
Host: "localhost",
|
Host: "localhost",
|
||||||
|
EmailTemplatesPath: "./email-templates",
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
|
|||||||
@@ -15,4 +15,5 @@ var (
|
|||||||
ErrOidcInvalidCallbackURL = errors.New("invalid callback URL")
|
ErrOidcInvalidCallbackURL = errors.New("invalid callback URL")
|
||||||
ErrFileTypeNotSupported = errors.New("file type not supported")
|
ErrFileTypeNotSupported = errors.New("file type not supported")
|
||||||
ErrInvalidCredentials = errors.New("no user found with provided credentials")
|
ErrInvalidCredentials = errors.New("no user found with provided credentials")
|
||||||
|
ErrNameAlreadyInUse = errors.New("name is already in use")
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -91,8 +91,20 @@ func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (acc *AppConfigController) getLogoHandler(c *gin.Context) {
|
func (acc *AppConfigController) getLogoHandler(c *gin.Context) {
|
||||||
imageType := acc.appConfigService.DbConfig.LogoImageType.Value
|
lightLogo := c.DefaultQuery("light", "true") == "true"
|
||||||
acc.getImage(c, "logo", imageType)
|
|
||||||
|
var imageName string
|
||||||
|
var imageType string
|
||||||
|
|
||||||
|
if lightLogo {
|
||||||
|
imageName = "logoLight"
|
||||||
|
imageType = acc.appConfigService.DbConfig.LogoLightImageType.Value
|
||||||
|
} else {
|
||||||
|
imageName = "logoDark"
|
||||||
|
imageType = acc.appConfigService.DbConfig.LogoDarkImageType.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.getImage(c, imageName, imageType)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (acc *AppConfigController) getFaviconHandler(c *gin.Context) {
|
func (acc *AppConfigController) getFaviconHandler(c *gin.Context) {
|
||||||
@@ -105,8 +117,20 @@ func (acc *AppConfigController) getBackgroundImageHandler(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
|
func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
|
||||||
imageType := acc.appConfigService.DbConfig.LogoImageType.Value
|
lightLogo := c.DefaultQuery("light", "true") == "true"
|
||||||
acc.updateImage(c, "logo", imageType)
|
|
||||||
|
var imageName string
|
||||||
|
var imageType string
|
||||||
|
|
||||||
|
if lightLogo {
|
||||||
|
imageName = "logoLight"
|
||||||
|
imageType = acc.appConfigService.DbConfig.LogoLightImageType.Value
|
||||||
|
} else {
|
||||||
|
imageName = "logoDark"
|
||||||
|
imageType = acc.appConfigService.DbConfig.LogoDarkImageType.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
acc.updateImage(c, imageName, imageType)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) {
|
func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) {
|
||||||
|
|||||||
162
backend/internal/controller/user_group_controller.go
Normal file
162
backend/internal/controller/user_group_controller.go
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/middleware"
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/service"
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewUserGroupController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, userGroupService *service.UserGroupService) {
|
||||||
|
ugc := UserGroupController{
|
||||||
|
UserGroupService: userGroupService,
|
||||||
|
}
|
||||||
|
|
||||||
|
group.GET("/user-groups", jwtAuthMiddleware.Add(true), ugc.list)
|
||||||
|
group.GET("/user-groups/:id", jwtAuthMiddleware.Add(true), ugc.get)
|
||||||
|
group.POST("/user-groups", jwtAuthMiddleware.Add(true), ugc.create)
|
||||||
|
group.PUT("/user-groups/:id", jwtAuthMiddleware.Add(true), ugc.update)
|
||||||
|
group.DELETE("/user-groups/:id", jwtAuthMiddleware.Add(true), ugc.delete)
|
||||||
|
group.PUT("/user-groups/:id/users", jwtAuthMiddleware.Add(true), ugc.updateUsers)
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserGroupController struct {
|
||||||
|
UserGroupService *service.UserGroupService
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ugc *UserGroupController) list(c *gin.Context) {
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
pageSize, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
||||||
|
searchTerm := c.Query("search")
|
||||||
|
|
||||||
|
groups, pagination, err := ugc.UserGroupService.List(searchTerm, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
utils.ControllerError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var groupsDto = make([]dto.UserGroupDtoWithUserCount, len(groups))
|
||||||
|
for i, group := range groups {
|
||||||
|
var groupDto dto.UserGroupDtoWithUserCount
|
||||||
|
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||||
|
utils.ControllerError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
groupDto.UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(group.ID)
|
||||||
|
if err != nil {
|
||||||
|
utils.ControllerError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
groupsDto[i] = groupDto
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"data": groupsDto,
|
||||||
|
"pagination": pagination,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ugc *UserGroupController) get(c *gin.Context) {
|
||||||
|
group, err := ugc.UserGroupService.Get(c.Param("id"))
|
||||||
|
if err != nil {
|
||||||
|
utils.ControllerError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var groupDto dto.UserGroupDtoWithUsers
|
||||||
|
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||||
|
utils.ControllerError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, groupDto)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ugc *UserGroupController) create(c *gin.Context) {
|
||||||
|
var input dto.UserGroupCreateDto
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
utils.ControllerError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
group, err := ugc.UserGroupService.Create(input)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, common.ErrNameAlreadyInUse) {
|
||||||
|
utils.CustomControllerError(c, http.StatusConflict, err.Error())
|
||||||
|
} else {
|
||||||
|
utils.ControllerError(c, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var groupDto dto.UserGroupDtoWithUsers
|
||||||
|
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||||
|
utils.ControllerError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, groupDto)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ugc *UserGroupController) update(c *gin.Context) {
|
||||||
|
var input dto.UserGroupCreateDto
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
utils.ControllerError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
group, err := ugc.UserGroupService.Update(c.Param("id"), input)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, common.ErrNameAlreadyInUse) {
|
||||||
|
utils.CustomControllerError(c, http.StatusConflict, err.Error())
|
||||||
|
} else {
|
||||||
|
utils.ControllerError(c, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var groupDto dto.UserGroupDtoWithUsers
|
||||||
|
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||||
|
utils.ControllerError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, groupDto)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ugc *UserGroupController) delete(c *gin.Context) {
|
||||||
|
if err := ugc.UserGroupService.Delete(c.Param("id")); err != nil {
|
||||||
|
utils.ControllerError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ugc *UserGroupController) updateUsers(c *gin.Context) {
|
||||||
|
var input dto.UserGroupUpdateUsersDto
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
utils.ControllerError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
group, err := ugc.UserGroupService.UpdateUsers(c.Param("id"), input)
|
||||||
|
if err != nil {
|
||||||
|
utils.ControllerError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var groupDto dto.UserGroupDtoWithUsers
|
||||||
|
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||||
|
utils.ControllerError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, groupDto)
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ type AuditLogDto struct {
|
|||||||
|
|
||||||
Event model.AuditLogEvent `json:"event"`
|
Event model.AuditLogEvent `json:"event"`
|
||||||
IpAddress string `json:"ipAddress"`
|
IpAddress string `json:"ipAddress"`
|
||||||
|
Country string `json:"country"`
|
||||||
|
City string `json:"city"`
|
||||||
Device string `json:"device"`
|
Device string `json:"device"`
|
||||||
UserID string `json:"userID"`
|
UserID string `json:"userID"`
|
||||||
Data model.AuditLogData `json:"data"`
|
Data model.AuditLogData `json:"data"`
|
||||||
|
|||||||
@@ -57,15 +57,37 @@ func mapStructInternal(sourceVal reflect.Value, destVal reflect.Value) error {
|
|||||||
// Handle direct assignment for simple types
|
// Handle direct assignment for simple types
|
||||||
if sourceField.Type() == destField.Type() {
|
if sourceField.Type() == destField.Type() {
|
||||||
destField.Set(sourceField)
|
destField.Set(sourceField)
|
||||||
|
|
||||||
} else if sourceField.Kind() == reflect.Slice && destField.Kind() == reflect.Slice {
|
} else if sourceField.Kind() == reflect.Slice && destField.Kind() == reflect.Slice {
|
||||||
// Handle slices
|
// Handle slices
|
||||||
if sourceField.Type().Elem() == destField.Type().Elem() {
|
if sourceField.Type().Elem() == destField.Type().Elem() {
|
||||||
|
// Direct assignment for slices of primitive types or non-struct elements
|
||||||
newSlice := reflect.MakeSlice(destField.Type(), sourceField.Len(), sourceField.Cap())
|
newSlice := reflect.MakeSlice(destField.Type(), sourceField.Len(), sourceField.Cap())
|
||||||
|
|
||||||
for j := 0; j < sourceField.Len(); j++ {
|
for j := 0; j < sourceField.Len(); j++ {
|
||||||
newSlice.Index(j).Set(sourceField.Index(j))
|
newSlice.Index(j).Set(sourceField.Index(j))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
destField.Set(newSlice)
|
||||||
|
|
||||||
|
} else if sourceField.Type().Elem().Kind() == reflect.Struct && destField.Type().Elem().Kind() == reflect.Struct {
|
||||||
|
// Recursively map slices of structs
|
||||||
|
newSlice := reflect.MakeSlice(destField.Type(), sourceField.Len(), sourceField.Cap())
|
||||||
|
|
||||||
|
for j := 0; j < sourceField.Len(); j++ {
|
||||||
|
// Get the element from both source and destination slice
|
||||||
|
sourceElem := sourceField.Index(j)
|
||||||
|
destElem := reflect.New(destField.Type().Elem()).Elem()
|
||||||
|
|
||||||
|
// Recursively map the struct elements
|
||||||
|
if err := mapStructInternal(sourceElem, destElem); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the mapped element in the new slice
|
||||||
|
newSlice.Index(j).Set(destElem)
|
||||||
|
}
|
||||||
|
|
||||||
destField.Set(newSlice)
|
destField.Set(newSlice)
|
||||||
}
|
}
|
||||||
} else if sourceField.Kind() == reflect.Struct && destField.Kind() == reflect.Struct {
|
} else if sourceField.Kind() == reflect.Struct && destField.Kind() == reflect.Struct {
|
||||||
|
|||||||
32
backend/internal/dto/user_group_dto.go
Normal file
32
backend/internal/dto/user_group_dto.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
type UserGroupDtoWithUsers struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
FriendlyName string `json:"friendlyName"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Users []UserDto `json:"users"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserGroupDtoWithUserCount struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
FriendlyName string `json:"friendlyName"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
UserCount int64 `json:"userCount"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserGroupCreateDto struct {
|
||||||
|
FriendlyName string `json:"friendlyName" binding:"required,min=3,max=30"`
|
||||||
|
Name string `json:"name" binding:"required,min=3,max=30,userGroupName"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserGroupUpdateUsersDto struct {
|
||||||
|
UserIDs []string `json:"userIds" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AssignUserToGroupDto struct {
|
||||||
|
UserID string `json:"userId" binding:"required"`
|
||||||
|
}
|
||||||
@@ -28,6 +28,13 @@ var validateUsername validator.Func = func(fl validator.FieldLevel) bool {
|
|||||||
return matched
|
return matched
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var validateUserGroupName validator.Func = func(fl validator.FieldLevel) bool {
|
||||||
|
// [a-z0-9_] : The group name can only contain lowercase letters, numbers, and underscores
|
||||||
|
regex := "^[a-z0-9_]+$"
|
||||||
|
matched, _ := regexp.MatchString(regex, fl.Field().String())
|
||||||
|
return matched
|
||||||
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
|
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
|
||||||
if err := v.RegisterValidation("urlList", validateUrlList); err != nil {
|
if err := v.RegisterValidation("urlList", validateUrlList); err != nil {
|
||||||
@@ -39,4 +46,10 @@ func init() {
|
|||||||
log.Fatalf("Failed to register custom validation: %v", err)
|
log.Fatalf("Failed to register custom validation: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
|
||||||
|
if err := v.RegisterValidation("userGroupName", validateUserGroupName); err != nil {
|
||||||
|
log.Fatalf("Failed to register custom validation: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ type AppConfigVariable struct {
|
|||||||
type AppConfig struct {
|
type AppConfig struct {
|
||||||
AppName AppConfigVariable
|
AppName AppConfigVariable
|
||||||
BackgroundImageType AppConfigVariable
|
BackgroundImageType AppConfigVariable
|
||||||
LogoImageType AppConfigVariable
|
LogoLightImageType AppConfigVariable
|
||||||
|
LogoDarkImageType AppConfigVariable
|
||||||
SessionDuration AppConfigVariable
|
SessionDuration AppConfigVariable
|
||||||
|
|
||||||
EmailEnabled AppConfigVariable
|
EmailEnabled AppConfigVariable
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ type AuditLog struct {
|
|||||||
|
|
||||||
Event AuditLogEvent
|
Event AuditLogEvent
|
||||||
IpAddress string
|
IpAddress string
|
||||||
|
Country string
|
||||||
|
City string
|
||||||
UserAgent string
|
UserAgent string
|
||||||
UserID string
|
UserID string
|
||||||
Data AuditLogData
|
Data AuditLogData
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type User struct {
|
|||||||
LastName string
|
LastName string
|
||||||
IsAdmin bool
|
IsAdmin bool
|
||||||
|
|
||||||
|
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
|
||||||
Credentials []WebauthnCredential
|
Credentials []WebauthnCredential
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
8
backend/internal/model/user_group.go
Normal file
8
backend/internal/model/user_group.go
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
type UserGroup struct {
|
||||||
|
Base
|
||||||
|
FriendlyName string
|
||||||
|
Name string `gorm:"unique"`
|
||||||
|
Users []User `gorm:"many2many:user_groups_users;"`
|
||||||
|
}
|
||||||
@@ -47,8 +47,14 @@ var defaultDbConfig = model.AppConfig{
|
|||||||
IsInternal: true,
|
IsInternal: true,
|
||||||
Value: "jpg",
|
Value: "jpg",
|
||||||
},
|
},
|
||||||
LogoImageType: model.AppConfigVariable{
|
LogoLightImageType: model.AppConfigVariable{
|
||||||
Key: "logoImageType",
|
Key: "logoLightImageType",
|
||||||
|
Type: "string",
|
||||||
|
IsInternal: true,
|
||||||
|
Value: "svg",
|
||||||
|
},
|
||||||
|
LogoDarkImageType: model.AppConfigVariable{
|
||||||
|
Key: "logoDarkImageType",
|
||||||
Type: "string",
|
Type: "string",
|
||||||
IsInternal: true,
|
IsInternal: true,
|
||||||
Value: "svg",
|
Value: "svg",
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
userAgentParser "github.com/mileusna/useragent"
|
userAgentParser "github.com/mileusna/useragent"
|
||||||
|
"github.com/oschwald/maxminddb-golang/v2"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/model"
|
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/utils"
|
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/utils/email"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"log"
|
"log"
|
||||||
|
"net/netip"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AuditLogService struct {
|
type AuditLogService struct {
|
||||||
@@ -20,9 +23,16 @@ func NewAuditLogService(db *gorm.DB, appConfigService *AppConfigService, emailSe
|
|||||||
|
|
||||||
// Create creates a new audit log entry in the database
|
// Create creates a new audit log entry in the database
|
||||||
func (s *AuditLogService) Create(event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData) model.AuditLog {
|
func (s *AuditLogService) Create(event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData) model.AuditLog {
|
||||||
|
country, city, err := s.GetIpLocation(ipAddress)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Failed to get IP location: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
auditLog := model.AuditLog{
|
auditLog := model.AuditLog{
|
||||||
Event: event,
|
Event: event,
|
||||||
IpAddress: ipAddress,
|
IpAddress: ipAddress,
|
||||||
|
Country: country,
|
||||||
|
City: city,
|
||||||
UserAgent: userAgent,
|
UserAgent: userAgent,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
Data: data,
|
Data: data,
|
||||||
@@ -55,14 +65,18 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ipAddress, userAgent, userID
|
|||||||
var user model.User
|
var user model.User
|
||||||
s.db.Where("id = ?", userID).First(&user)
|
s.db.Where("id = ?", userID).First(&user)
|
||||||
|
|
||||||
title := "New device login with " + s.appConfigService.DbConfig.AppName.Value
|
err := SendEmail(s.emailService, email.Address{
|
||||||
err := s.emailService.Send(user.Email, title, "login-with-new-device", map[string]interface{}{
|
Name: user.Username,
|
||||||
"ipAddress": ipAddress,
|
Email: user.Email,
|
||||||
"device": s.DeviceStringFromUserAgent(userAgent),
|
}, NewLoginTemplate, &NewLoginTemplateData{
|
||||||
"dateTimeString": createdAuditLog.CreatedAt.UTC().Format("2006-01-02 15:04:05 UTC"),
|
IPAddress: ipAddress,
|
||||||
|
Country: createdAuditLog.Country,
|
||||||
|
City: createdAuditLog.City,
|
||||||
|
Device: s.DeviceStringFromUserAgent(userAgent),
|
||||||
|
DateTime: createdAuditLog.CreatedAt.UTC(),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to send email: %v\n", err)
|
log.Printf("Failed to send email to '%s': %v\n", user.Email, err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
@@ -83,3 +97,29 @@ func (s *AuditLogService) DeviceStringFromUserAgent(userAgent string) string {
|
|||||||
ua := userAgentParser.Parse(userAgent)
|
ua := userAgentParser.Parse(userAgent)
|
||||||
return ua.Name + " on " + ua.OS + " " + ua.OSVersion
|
return ua.Name + " on " + ua.OS + " " + ua.OSVersion
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *AuditLogService) GetIpLocation(ipAddress string) (country, city string, err error) {
|
||||||
|
db, err := maxminddb.Open("GeoLite2-City.mmdb")
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
|
||||||
|
addr := netip.MustParseAddr(ipAddress)
|
||||||
|
|
||||||
|
var record struct {
|
||||||
|
City struct {
|
||||||
|
Names map[string]string `maxminddb:"names"`
|
||||||
|
} `maxminddb:"city"`
|
||||||
|
Country struct {
|
||||||
|
Names map[string]string `maxminddb:"names"`
|
||||||
|
} `maxminddb:"country"`
|
||||||
|
}
|
||||||
|
|
||||||
|
err = db.Lookup(addr).Decode(&record)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return record.Country.Names["en"], record.City.Names["en"], nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,62 +1,90 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/stonith404/pocket-id/backend/internal/common"
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/utils/email"
|
||||||
|
htemplate "html/template"
|
||||||
|
"io/fs"
|
||||||
|
"mime/multipart"
|
||||||
|
"mime/quotedprintable"
|
||||||
"net/smtp"
|
"net/smtp"
|
||||||
"os"
|
"net/textproto"
|
||||||
"strings"
|
ttemplate "text/template"
|
||||||
)
|
)
|
||||||
|
|
||||||
type EmailService struct {
|
type EmailService struct {
|
||||||
appConfigService *AppConfigService
|
appConfigService *AppConfigService
|
||||||
|
htmlTemplates map[string]*htemplate.Template
|
||||||
|
textTemplates map[string]*ttemplate.Template
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewEmailService(appConfigService *AppConfigService) *EmailService {
|
func NewEmailService(appConfigService *AppConfigService, templateDir fs.FS) (*EmailService, error) {
|
||||||
|
htmlTemplates, err := email.PrepareHTMLTemplates(templateDir, emailTemplatesPaths)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("prepare html templates: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
textTemplates, err := email.PrepareTextTemplates(templateDir, emailTemplatesPaths)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("prepare html templates: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return &EmailService{
|
return &EmailService{
|
||||||
appConfigService: appConfigService}
|
appConfigService: appConfigService,
|
||||||
|
htmlTemplates: htmlTemplates,
|
||||||
|
textTemplates: textTemplates,
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send sends an email notification
|
func SendEmail[V any](srv *EmailService, toEmail email.Address, template email.Template[V], tData *V) error {
|
||||||
func (s *EmailService) Send(toEmail, title, templateName string, templateParameters map[string]interface{}) error {
|
|
||||||
// Check if SMTP settings are set
|
// Check if SMTP settings are set
|
||||||
if s.appConfigService.DbConfig.EmailEnabled.Value != "true" {
|
if srv.appConfigService.DbConfig.EmailEnabled.Value != "true" {
|
||||||
return errors.New("email not enabled")
|
return errors.New("email not enabled")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Construct the email message
|
data := &email.TemplateData[V]{
|
||||||
subject := fmt.Sprintf("Subject: %s\n", title)
|
AppName: srv.appConfigService.DbConfig.AppName.Value,
|
||||||
subject += "From: " + s.appConfigService.DbConfig.SmtpFrom.Value + "\n"
|
LogoURL: common.EnvConfig.AppURL + "/api/application-configuration/logo",
|
||||||
subject += "To: " + toEmail + "\n"
|
Data: tData,
|
||||||
subject += "Content-Type: text/html; charset=UTF-8\n"
|
}
|
||||||
|
|
||||||
body, err := os.ReadFile(fmt.Sprintf("./email-templates/%s.html", templateName))
|
body, boundary, err := prepareBody(srv, template, data)
|
||||||
bodyString := string(body)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read email template: %w", err)
|
return fmt.Errorf("prepare email body for '%s': %w", template.Path, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace template parameters
|
// Construct the email message
|
||||||
templateParameters["appName"] = s.appConfigService.DbConfig.AppName.Value
|
c := email.NewComposer()
|
||||||
templateParameters["appUrl"] = common.EnvConfig.AppURL
|
c.AddHeader("Subject", template.Title(data))
|
||||||
|
c.AddAddressHeader("From", []email.Address{
|
||||||
for key, value := range templateParameters {
|
{
|
||||||
bodyString = strings.ReplaceAll(bodyString, fmt.Sprintf("{{%s}}", key), fmt.Sprintf("%v", value))
|
Email: srv.appConfigService.DbConfig.SmtpFrom.Value,
|
||||||
}
|
Name: srv.appConfigService.DbConfig.AppName.Value,
|
||||||
|
},
|
||||||
emailBody := []byte(subject + bodyString)
|
})
|
||||||
|
c.AddAddressHeader("To", []email.Address{toEmail})
|
||||||
|
c.AddHeaderRaw("Content-Type",
|
||||||
|
fmt.Sprintf("multipart/alternative;\n boundary=%s;\n charset=UTF-8", boundary),
|
||||||
|
)
|
||||||
|
c.Body(body)
|
||||||
|
|
||||||
// Set up the authentication information.
|
// Set up the authentication information.
|
||||||
auth := smtp.PlainAuth("", s.appConfigService.DbConfig.SmtpUser.Value, s.appConfigService.DbConfig.SmtpPassword.Value, s.appConfigService.DbConfig.SmtpHost.Value)
|
auth := smtp.PlainAuth("",
|
||||||
|
srv.appConfigService.DbConfig.SmtpUser.Value,
|
||||||
|
srv.appConfigService.DbConfig.SmtpPassword.Value,
|
||||||
|
srv.appConfigService.DbConfig.SmtpHost.Value,
|
||||||
|
)
|
||||||
|
|
||||||
// Send the email
|
// Send the email
|
||||||
err = smtp.SendMail(
|
err = smtp.SendMail(
|
||||||
s.appConfigService.DbConfig.SmtpHost.Value+":"+s.appConfigService.DbConfig.SmtpPort.Value,
|
srv.appConfigService.DbConfig.SmtpHost.Value+":"+srv.appConfigService.DbConfig.SmtpPort.Value,
|
||||||
auth,
|
auth,
|
||||||
s.appConfigService.DbConfig.SmtpFrom.Value,
|
srv.appConfigService.DbConfig.SmtpFrom.Value,
|
||||||
[]string{toEmail},
|
[]string{toEmail.Email},
|
||||||
emailBody,
|
[]byte(c.String()),
|
||||||
)
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -65,3 +93,45 @@ func (s *EmailService) Send(toEmail, title, templateName string, templateParamet
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func prepareBody[V any](srv *EmailService, template email.Template[V], data *email.TemplateData[V]) (string, string, error) {
|
||||||
|
body := bytes.NewBuffer(nil)
|
||||||
|
mpart := multipart.NewWriter(body)
|
||||||
|
|
||||||
|
// prepare text part
|
||||||
|
var textHeader = textproto.MIMEHeader{}
|
||||||
|
textHeader.Add("Content-Type", "text/plain;\n charset=UTF-8")
|
||||||
|
textHeader.Add("Content-Transfer-Encoding", "quoted-printable")
|
||||||
|
textPart, err := mpart.CreatePart(textHeader)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("create text part: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
textQp := quotedprintable.NewWriter(textPart)
|
||||||
|
err = email.GetTemplate(srv.textTemplates, template).ExecuteTemplate(textQp, "root", data)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("execute text template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// prepare html part
|
||||||
|
var htmlHeader = textproto.MIMEHeader{}
|
||||||
|
htmlHeader.Add("Content-Type", "text/html;\n charset=UTF-8")
|
||||||
|
htmlHeader.Add("Content-Transfer-Encoding", "quoted-printable")
|
||||||
|
htmlPart, err := mpart.CreatePart(htmlHeader)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("create html part: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
htmlQp := quotedprintable.NewWriter(htmlPart)
|
||||||
|
err = email.GetTemplate(srv.htmlTemplates, template).ExecuteTemplate(htmlQp, "root", data)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("execute html template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = mpart.Close()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("close multipart: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return body.String(), mpart.Boundary(), nil
|
||||||
|
}
|
||||||
|
|||||||
39
backend/internal/service/email_service_templates.go
Normal file
39
backend/internal/service/email_service_templates.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/utils/email"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
How to add new template:
|
||||||
|
- pick unique and descriptive template ${name} (for example "login-with-new-device")
|
||||||
|
- in backend/email-templates/ create "${name}_html.tmpl" and "${name}_text.tmpl"
|
||||||
|
- create xxxxTemplate and xxxxTemplateData (for example NewLoginTemplate and NewLoginTemplateData)
|
||||||
|
- Path *must* be ${name}
|
||||||
|
- add xxxTemplate.Path to "emailTemplatePaths" at the end
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- backend app must be restarted to reread all the template files
|
||||||
|
- root "." object in templates is `email.TemplateData`
|
||||||
|
- xxxxTemplateData structure is visible under .Data in templates
|
||||||
|
*/
|
||||||
|
|
||||||
|
var NewLoginTemplate = email.Template[NewLoginTemplateData]{
|
||||||
|
Path: "login-with-new-device",
|
||||||
|
Title: func(data *email.TemplateData[NewLoginTemplateData]) string {
|
||||||
|
return fmt.Sprintf("New device login with %s", data.AppName)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
type NewLoginTemplateData struct {
|
||||||
|
IPAddress string
|
||||||
|
Country string
|
||||||
|
City string
|
||||||
|
Device string
|
||||||
|
DateTime time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// this is list of all template paths used for preloading templates
|
||||||
|
var emailTemplatesPaths = []string{NewLoginTemplate.Path}
|
||||||
@@ -3,6 +3,7 @@ package service
|
|||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/rsa"
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
@@ -51,6 +52,7 @@ type AccessTokenJWTClaims struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type JWK struct {
|
type JWK struct {
|
||||||
|
Kid string `json:"kid"`
|
||||||
Kty string `json:"kty"`
|
Kty string `json:"kty"`
|
||||||
Use string `json:"use"`
|
Use string `json:"use"`
|
||||||
Alg string `json:"alg"`
|
Alg string `json:"alg"`
|
||||||
@@ -98,7 +100,15 @@ func (s *JwtService) GenerateAccessToken(user model.User) (string, error) {
|
|||||||
},
|
},
|
||||||
IsAdmin: user.IsAdmin,
|
IsAdmin: user.IsAdmin,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kid, err := s.generateKeyID(s.publicKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("failed to generate key ID: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
|
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
|
||||||
|
token.Header["kid"] = kid
|
||||||
|
|
||||||
return token.SignedString(s.privateKey)
|
return token.SignedString(s.privateKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,9 +147,17 @@ func (s *JwtService) GenerateIDToken(userClaims map[string]interface{}, clientID
|
|||||||
claims["nonce"] = nonce
|
claims["nonce"] = nonce
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kid, err := s.generateKeyID(s.publicKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("failed to generate key ID: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
|
||||||
|
token.Header["kid"] = kid
|
||||||
|
|
||||||
return token.SignedString(s.privateKey)
|
return token.SignedString(s.privateKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string) (string, error) {
|
func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string) (string, error) {
|
||||||
claim := jwt.RegisteredClaims{
|
claim := jwt.RegisteredClaims{
|
||||||
Subject: user.ID,
|
Subject: user.ID,
|
||||||
@@ -148,7 +166,15 @@ func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string)
|
|||||||
Audience: jwt.ClaimStrings{clientID},
|
Audience: jwt.ClaimStrings{clientID},
|
||||||
Issuer: common.EnvConfig.AppURL,
|
Issuer: common.EnvConfig.AppURL,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kid, err := s.generateKeyID(s.publicKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("failed to generate key ID: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
|
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claim)
|
||||||
|
token.Header["kid"] = kid
|
||||||
|
|
||||||
return token.SignedString(s.privateKey)
|
return token.SignedString(s.privateKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +200,13 @@ func (s *JwtService) GetJWK() (JWK, error) {
|
|||||||
return JWK{}, errors.New("public key is not initialized")
|
return JWK{}, errors.New("public key is not initialized")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
kid, err := s.generateKeyID(s.publicKey)
|
||||||
|
if err != nil {
|
||||||
|
return JWK{}, err
|
||||||
|
}
|
||||||
|
|
||||||
jwk := JWK{
|
jwk := JWK{
|
||||||
|
Kid: kid,
|
||||||
Kty: "RSA",
|
Kty: "RSA",
|
||||||
Use: "sig",
|
Use: "sig",
|
||||||
Alg: "RS256",
|
Alg: "RS256",
|
||||||
@@ -185,6 +217,25 @@ func (s *JwtService) GetJWK() (JWK, error) {
|
|||||||
return jwk, nil
|
return jwk, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GenerateKeyID generates a Key ID for the public key using the first 8 bytes of the SHA-256 hash of the public key.
|
||||||
|
func (s *JwtService) generateKeyID(publicKey *rsa.PublicKey) (string, error) {
|
||||||
|
pubASN1, err := x509.MarshalPKIXPublicKey(publicKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("failed to marshal public key: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute SHA-256 hash of the public key
|
||||||
|
hash := sha256.New()
|
||||||
|
hash.Write(pubASN1)
|
||||||
|
hashed := hash.Sum(nil)
|
||||||
|
|
||||||
|
// Truncate the hash to the first 8 bytes for a shorter Key ID
|
||||||
|
shortHash := hashed[:8]
|
||||||
|
|
||||||
|
// Return Base64 encoded truncated hash as Key ID
|
||||||
|
return base64.RawURLEncoding.EncodeToString(shortHash), nil
|
||||||
|
}
|
||||||
|
|
||||||
// generateKeys generates a new RSA key pair and saves them to the specified paths.
|
// generateKeys generates a new RSA key pair and saves them to the specified paths.
|
||||||
func (s *JwtService) generateKeys() error {
|
func (s *JwtService) generateKeys() error {
|
||||||
if err := os.MkdirAll(filepath.Dir(privateKeyPath), 0700); err != nil {
|
if err := os.MkdirAll(filepath.Dir(privateKeyPath), 0700); err != nil {
|
||||||
|
|||||||
@@ -301,7 +301,7 @@ func (s *OidcService) DeleteClientLogo(clientID string) error {
|
|||||||
|
|
||||||
func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (map[string]interface{}, error) {
|
func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (map[string]interface{}, error) {
|
||||||
var authorizedOidcClient model.UserAuthorizedOidcClient
|
var authorizedOidcClient model.UserAuthorizedOidcClient
|
||||||
if err := s.db.Preload("User").First(&authorizedOidcClient, "user_id = ? AND client_id = ?", userID, clientID).Error; err != nil {
|
if err := s.db.Preload("User.UserGroups").First(&authorizedOidcClient, "user_id = ? AND client_id = ?", userID, clientID).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,6 +316,14 @@ func (s *OidcService) GetUserClaimsForClient(userID string, clientID string) (ma
|
|||||||
claims["email"] = user.Email
|
claims["email"] = user.Email
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.Contains(scope, "groups") {
|
||||||
|
userGroups := make([]string, len(user.UserGroups))
|
||||||
|
for i, group := range user.UserGroups {
|
||||||
|
userGroups[i] = group.Name
|
||||||
|
}
|
||||||
|
claims["groups"] = userGroups
|
||||||
|
}
|
||||||
|
|
||||||
profileClaims := map[string]interface{}{
|
profileClaims := map[string]interface{}{
|
||||||
"given_name": user.FirstName,
|
"given_name": user.FirstName,
|
||||||
"family_name": user.LastName,
|
"family_name": user.LastName,
|
||||||
|
|||||||
@@ -56,6 +56,30 @@ func (s *TestService) SeedDatabase() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
userGroups := []model.UserGroup{
|
||||||
|
{
|
||||||
|
Base: model.Base{
|
||||||
|
ID: "4110f814-56f1-4b28-8998-752b69bc97c0e",
|
||||||
|
},
|
||||||
|
Name: "developers",
|
||||||
|
FriendlyName: "Developers",
|
||||||
|
Users: []model.User{users[0], users[1]},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Base: model.Base{
|
||||||
|
ID: "adab18bf-f89d-4087-9ee1-70ff15b48211",
|
||||||
|
},
|
||||||
|
Name: "designers",
|
||||||
|
FriendlyName: "Designers",
|
||||||
|
Users: []model.User{users[0]},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, group := range userGroups {
|
||||||
|
if err := tx.Create(&group).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
oidcClients := []model.OidcClient{
|
oidcClients := []model.OidcClient{
|
||||||
{
|
{
|
||||||
Base: model.Base{
|
Base: model.Base{
|
||||||
|
|||||||
111
backend/internal/service/user_group_service.go
Normal file
111
backend/internal/service/user_group_service.go
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/common"
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/dto"
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/model"
|
||||||
|
"github.com/stonith404/pocket-id/backend/internal/utils"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type UserGroupService struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewUserGroupService(db *gorm.DB) *UserGroupService {
|
||||||
|
return &UserGroupService{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserGroupService) List(name string, page int, pageSize int) (groups []model.UserGroup, response utils.PaginationResponse, err error) {
|
||||||
|
query := s.db.Model(&model.UserGroup{})
|
||||||
|
|
||||||
|
if name != "" {
|
||||||
|
query = query.Where("name LIKE ?", "%"+name+"%")
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err = utils.Paginate(page, pageSize, query, &groups)
|
||||||
|
return groups, response, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserGroupService) Get(id string) (group model.UserGroup, err error) {
|
||||||
|
err = s.db.Where("id = ?", id).Preload("Users").First(&group).Error
|
||||||
|
return group, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserGroupService) Delete(id string) error {
|
||||||
|
var group model.UserGroup
|
||||||
|
if err := s.db.Where("id = ?", id).First(&group).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.db.Delete(&group).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserGroupService) Create(input dto.UserGroupCreateDto) (group model.UserGroup, err error) {
|
||||||
|
group = model.UserGroup{
|
||||||
|
FriendlyName: input.FriendlyName,
|
||||||
|
Name: input.Name,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.db.Preload("Users").Create(&group).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||||
|
return model.UserGroup{}, common.ErrNameAlreadyInUse
|
||||||
|
}
|
||||||
|
return model.UserGroup{}, err
|
||||||
|
}
|
||||||
|
return group, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserGroupService) Update(id string, input dto.UserGroupCreateDto) (group model.UserGroup, err error) {
|
||||||
|
group, err = s.Get(id)
|
||||||
|
if err != nil {
|
||||||
|
return model.UserGroup{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
group.Name = input.Name
|
||||||
|
group.FriendlyName = input.FriendlyName
|
||||||
|
|
||||||
|
if err := s.db.Preload("Users").Save(&group).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||||
|
return model.UserGroup{}, common.ErrNameAlreadyInUse
|
||||||
|
}
|
||||||
|
return model.UserGroup{}, err
|
||||||
|
}
|
||||||
|
return group, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserGroupService) UpdateUsers(id string, input dto.UserGroupUpdateUsersDto) (group model.UserGroup, err error) {
|
||||||
|
group, err = s.Get(id)
|
||||||
|
if err != nil {
|
||||||
|
return model.UserGroup{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the users based on UserIDs in input
|
||||||
|
var users []model.User
|
||||||
|
if len(input.UserIDs) > 0 {
|
||||||
|
if err := s.db.Where("id IN (?)", input.UserIDs).Find(&users).Error; err != nil {
|
||||||
|
return model.UserGroup{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace the current users with the new set of users
|
||||||
|
if err := s.db.Model(&group).Association("Users").Replace(users); err != nil {
|
||||||
|
return model.UserGroup{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the updated group
|
||||||
|
if err := s.db.Save(&group).Error; err != nil {
|
||||||
|
return model.UserGroup{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return group, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *UserGroupService) GetUserCountOfGroup(id string) (int64, error) {
|
||||||
|
var group model.UserGroup
|
||||||
|
if err := s.db.Preload("Users").Where("id = ?", id).First(&group).Error; err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return s.db.Model(&group).Association("Users").Count(), nil
|
||||||
|
}
|
||||||
@@ -12,10 +12,11 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type WebAuthnService struct {
|
type WebAuthnService struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
webAuthn *webauthn.WebAuthn
|
webAuthn *webauthn.WebAuthn
|
||||||
jwtService *JwtService
|
jwtService *JwtService
|
||||||
auditLogService *AuditLogService
|
auditLogService *AuditLogService
|
||||||
|
appConfigService *AppConfigService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWebAuthnService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, appConfigService *AppConfigService) *WebAuthnService {
|
func NewWebAuthnService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, appConfigService *AppConfigService) *WebAuthnService {
|
||||||
@@ -36,12 +37,13 @@ func NewWebAuthnService(db *gorm.DB, jwtService *JwtService, auditLogService *Au
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
wa, _ := webauthn.New(webauthnConfig)
|
wa, _ := webauthn.New(webauthnConfig)
|
||||||
return &WebAuthnService{db: db, webAuthn: wa, jwtService: jwtService, auditLogService: auditLogService}
|
return &WebAuthnService{db: db, webAuthn: wa, jwtService: jwtService, auditLogService: auditLogService, appConfigService: appConfigService}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *WebAuthnService) BeginRegistration(userID string) (*model.PublicKeyCredentialCreationOptions, error) {
|
func (s *WebAuthnService) BeginRegistration(userID string) (*model.PublicKeyCredentialCreationOptions, error) {
|
||||||
|
s.updateWebAuthnConfig()
|
||||||
|
|
||||||
var user model.User
|
var user model.User
|
||||||
if err := s.db.Preload("Credentials").Find(&user, "id = ?", userID).Error; err != nil {
|
if err := s.db.Preload("Credentials").Find(&user, "id = ?", userID).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -203,3 +205,8 @@ func (s *WebAuthnService) UpdateCredential(userID, credentialID, name string) (m
|
|||||||
|
|
||||||
return credential, nil
|
return credential, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateWebAuthnConfig updates the WebAuthn configuration with the app name as it can change during runtime
|
||||||
|
func (s *WebAuthnService) updateWebAuthnConfig() {
|
||||||
|
s.webAuthn.Config.RPDisplayName = s.appConfigService.DbConfig.AppName.Value
|
||||||
|
}
|
||||||
|
|||||||
213
backend/internal/utils/email/composer.go
Normal file
213
backend/internal/utils/email/composer.go
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxLineLength = 78
|
||||||
|
const continuePrefix = " "
|
||||||
|
const addressSeparator = ", "
|
||||||
|
|
||||||
|
type Composer struct {
|
||||||
|
isClosed bool
|
||||||
|
content strings.Builder
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewComposer() *Composer {
|
||||||
|
return &Composer{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Address struct {
|
||||||
|
Name string
|
||||||
|
Email string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Composer) AddAddressHeader(name string, addresses []Address) {
|
||||||
|
c.content.WriteString(genAddressHeader(name, addresses, maxLineLength))
|
||||||
|
c.content.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func genAddressHeader(name string, addresses []Address, maxLength int) string {
|
||||||
|
hl := &headerLine{
|
||||||
|
maxLineLength: maxLength,
|
||||||
|
continuePrefix: continuePrefix,
|
||||||
|
}
|
||||||
|
|
||||||
|
hl.Write(name)
|
||||||
|
hl.Write(": ")
|
||||||
|
|
||||||
|
for i, addr := range addresses {
|
||||||
|
var email string
|
||||||
|
if i < len(addresses)-1 {
|
||||||
|
email = fmt.Sprintf("<%s>%s", addr.Email, addressSeparator)
|
||||||
|
} else {
|
||||||
|
email = fmt.Sprintf("<%s>", addr.Email)
|
||||||
|
}
|
||||||
|
writeHeaderQ(hl, addr.Name)
|
||||||
|
writeHeaderAtom(hl, " ")
|
||||||
|
writeHeaderAtom(hl, email)
|
||||||
|
}
|
||||||
|
hl.EndLine()
|
||||||
|
return hl.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Composer) AddHeader(name, value string) {
|
||||||
|
if isPrintableASCII(value) && len(value)+len(name)+len(": ") < maxLineLength {
|
||||||
|
c.AddHeaderRaw(name, value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.content.WriteString(genHeader(name, value, maxLineLength))
|
||||||
|
c.content.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func genHeader(name, value string, maxLength int) string {
|
||||||
|
// add content as raw header when it is printable ASCII and shorter than maxLineLength
|
||||||
|
hl := &headerLine{
|
||||||
|
maxLineLength: maxLength,
|
||||||
|
continuePrefix: continuePrefix,
|
||||||
|
}
|
||||||
|
|
||||||
|
hl.Write(name)
|
||||||
|
hl.Write(": ")
|
||||||
|
writeHeaderQ(hl, value)
|
||||||
|
hl.EndLine()
|
||||||
|
return hl.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
const qEncStart = "=?utf-8?q?"
|
||||||
|
const qEncEnd = "?="
|
||||||
|
|
||||||
|
type headerLine struct {
|
||||||
|
buffer strings.Builder
|
||||||
|
line strings.Builder
|
||||||
|
maxLineLength int
|
||||||
|
continuePrefix string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *headerLine) FitsLine(length int) bool {
|
||||||
|
return h.line.Len()+len(h.continuePrefix)+length+2 < h.maxLineLength
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *headerLine) Write(str string) {
|
||||||
|
h.line.WriteString(str)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *headerLine) EndLineWith(str string) {
|
||||||
|
h.line.WriteString(str)
|
||||||
|
h.EndLine()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *headerLine) EndLine() {
|
||||||
|
if h.line.Len() == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.buffer.Len() != 0 {
|
||||||
|
h.buffer.WriteString("\n")
|
||||||
|
h.buffer.WriteString(h.continuePrefix)
|
||||||
|
}
|
||||||
|
h.buffer.WriteString(h.line.String())
|
||||||
|
h.line.Reset()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *headerLine) String() string {
|
||||||
|
return h.buffer.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeHeaderQ(header *headerLine, value string) {
|
||||||
|
|
||||||
|
// current line does not fit event the first character - do \n
|
||||||
|
if !header.FitsLine(len(qEncStart) + len(convertRunes(value[0:1])[0]) + len(qEncEnd)) {
|
||||||
|
header.EndLineWith("")
|
||||||
|
}
|
||||||
|
|
||||||
|
header.Write(qEncStart)
|
||||||
|
|
||||||
|
for _, token := range convertRunes(value) {
|
||||||
|
if header.FitsLine(len(token) + len(qEncEnd)) {
|
||||||
|
header.Write(token)
|
||||||
|
} else {
|
||||||
|
header.EndLineWith(qEncEnd)
|
||||||
|
header.Write(qEncStart)
|
||||||
|
header.Write(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
header.Write(qEncEnd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeHeaderAtom(header *headerLine, value string) {
|
||||||
|
if !header.FitsLine(len(value)) {
|
||||||
|
header.EndLine()
|
||||||
|
}
|
||||||
|
header.Write(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Composer) AddHeaderRaw(name, value string) {
|
||||||
|
if c.isClosed {
|
||||||
|
panic("composer had already written body!")
|
||||||
|
}
|
||||||
|
header := fmt.Sprintf("%s: %s\n", name, value)
|
||||||
|
c.content.WriteString(header)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Composer) Body(body string) {
|
||||||
|
c.content.WriteString("\n")
|
||||||
|
c.content.WriteString(body)
|
||||||
|
c.isClosed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Composer) String() string {
|
||||||
|
return c.content.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertRunes(str string) []string {
|
||||||
|
var enc = make([]string, 0, len(str))
|
||||||
|
for _, r := range []rune(str) {
|
||||||
|
if r == ' ' {
|
||||||
|
enc = append(enc, "_")
|
||||||
|
} else if isPrintableASCIIRune(r) &&
|
||||||
|
r != '=' &&
|
||||||
|
r != '?' &&
|
||||||
|
r != '_' {
|
||||||
|
enc = append(enc, string(r))
|
||||||
|
} else {
|
||||||
|
enc = append(enc, string(toHex([]byte(string(r)))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return enc
|
||||||
|
}
|
||||||
|
|
||||||
|
func toHex(in []byte) []byte {
|
||||||
|
enc := make([]byte, 0, len(in)*2)
|
||||||
|
for _, b := range in {
|
||||||
|
enc = append(enc, '=')
|
||||||
|
enc = append(enc, hex(b/16))
|
||||||
|
enc = append(enc, hex(b%16))
|
||||||
|
}
|
||||||
|
return enc
|
||||||
|
}
|
||||||
|
|
||||||
|
func hex(n byte) byte {
|
||||||
|
if n > 9 {
|
||||||
|
return n + (65 - 10)
|
||||||
|
} else {
|
||||||
|
return n + 48
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPrintableASCII(str string) bool {
|
||||||
|
for _, r := range []rune(str) {
|
||||||
|
if !unicode.IsPrint(r) || r >= unicode.MaxASCII {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPrintableASCIIRune(r rune) bool {
|
||||||
|
return r > 31 && r < 127
|
||||||
|
}
|
||||||
92
backend/internal/utils/email/composer_test.go
Normal file
92
backend/internal/utils/email/composer_test.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConvertRunes(t *testing.T) {
|
||||||
|
var testData = map[string]string{
|
||||||
|
"=??=_.": "=3D=3F=3F=3D=5F.",
|
||||||
|
"Příšerně žluťoučký kůn úpěl ďábelské ódy 🐎": "P=C5=99=C3=AD=C5=A1ern=C4=9B_=C5=BElu=C5=A5ou=C4=8Dk=C3=BD_k=C5=AFn_=C3=BAp=C4=9Bl_=C4=8F=C3=A1belsk=C3=A9_=C3=B3dy_=F0=9F=90=8E",
|
||||||
|
}
|
||||||
|
for input, expected := range testData {
|
||||||
|
got := strings.Join(convertRunes(input), "")
|
||||||
|
if got != expected {
|
||||||
|
t.Errorf("Input: '%s', expected '%s', got: '%s'", input, expected, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type genHeaderTestData struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
expected string
|
||||||
|
maxWidth int
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenHeaderQ(t *testing.T) {
|
||||||
|
var testData = []genHeaderTestData{
|
||||||
|
{
|
||||||
|
name: "Subject",
|
||||||
|
value: "Příšerně žluťoučký kůn úpěl ďábelské ódy 🐎",
|
||||||
|
expected: "Subject: =?utf-8?q?P=C5=99=C3=AD=C5=A1ern=C4=9B_=C5=BElu=C5=A5ou=C4=8Dk?=\n" +
|
||||||
|
" =?utf-8?q?=C3=BD_k=C5=AFn_=C3=BAp=C4=9Bl_=C4=8F=C3=A1belsk=C3=A9_=C3=B3?=\n" +
|
||||||
|
" =?utf-8?q?dy_=F0=9F=90=8E?=",
|
||||||
|
maxWidth: 80,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, data := range testData {
|
||||||
|
got := genHeader(data.name, data.value, data.maxWidth)
|
||||||
|
if got != data.expected {
|
||||||
|
t.Errorf("Input: '%s', expected \n===\n%s\n===, got: \n===\n%s\n==='", data.value, data.expected, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type genAddressHeaderTestData struct {
|
||||||
|
name string
|
||||||
|
addresses []Address
|
||||||
|
expected string
|
||||||
|
maxLength int
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenAddressHeader(t *testing.T) {
|
||||||
|
var testData = []genAddressHeaderTestData{
|
||||||
|
{
|
||||||
|
name: "To",
|
||||||
|
addresses: []Address{
|
||||||
|
{
|
||||||
|
Name: "Oldřich Jánský",
|
||||||
|
Email: "olrd@example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: "To: =?utf-8?q?Old=C5=99ich_J=C3=A1nsk=C3=BD?= <olrd@example.com>",
|
||||||
|
maxLength: 80,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Subject",
|
||||||
|
addresses: []Address{
|
||||||
|
{
|
||||||
|
Name: "Oldřich Jánský",
|
||||||
|
Email: "olrd@example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "Jan Novák",
|
||||||
|
Email: "novak@example.com",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expected: "Subject: =?utf-8?q?Old=C5=99ich_J=C3=A1nsk=C3=BD?= <olrd@example.com>, \n" +
|
||||||
|
" =?utf-8?q?Jan_Nov=C3=A1k?= <novak@example.com>",
|
||||||
|
maxLength: 80,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, data := range testData {
|
||||||
|
got := genAddressHeader(data.name, data.addresses, data.maxLength)
|
||||||
|
if got != data.expected {
|
||||||
|
t.Errorf("Test: '%s', expected \n===\n%s\n===, got: \n===\n%s\n==='", data.name, data.expected, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
97
backend/internal/utils/email/email_service_templates.go
Normal file
97
backend/internal/utils/email/email_service_templates.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package email
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
htemplate "html/template"
|
||||||
|
"io/fs"
|
||||||
|
"path"
|
||||||
|
ttemplate "text/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
const templateComponentsDir = "components"
|
||||||
|
|
||||||
|
type Template[V any] struct {
|
||||||
|
Path string
|
||||||
|
Title func(data *TemplateData[V]) string
|
||||||
|
}
|
||||||
|
|
||||||
|
type TemplateData[V any] struct {
|
||||||
|
AppName string
|
||||||
|
LogoURL string
|
||||||
|
Data *V
|
||||||
|
}
|
||||||
|
|
||||||
|
type TemplateMap[V any] map[string]*V
|
||||||
|
|
||||||
|
func GetTemplate[U any, V any](templateMap TemplateMap[U], template Template[V]) *U {
|
||||||
|
return templateMap[template.Path]
|
||||||
|
}
|
||||||
|
|
||||||
|
type clonable[V pareseable[V]] interface {
|
||||||
|
Clone() (V, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type pareseable[V any] interface {
|
||||||
|
ParseFS(fs.FS, ...string) (V, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func prepareTemplate[V pareseable[V]](template string, rootTemplate clonable[V], templateDir fs.FS, suffix string) (V, error) {
|
||||||
|
tmpl, err := rootTemplate.Clone()
|
||||||
|
if err != nil {
|
||||||
|
return *new(V), fmt.Errorf("clone root html template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := fmt.Sprintf("%s%s", template, suffix)
|
||||||
|
_, err = tmpl.ParseFS(templateDir, filename)
|
||||||
|
if err != nil {
|
||||||
|
return *new(V), fmt.Errorf("parsing html template '%s': %w", template, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tmpl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func PrepareTextTemplates(templateDir fs.FS, templates []string) (map[string]*ttemplate.Template, error) {
|
||||||
|
components := path.Join(templateComponentsDir, "*_text.tmpl")
|
||||||
|
rootTmpl, err := ttemplate.ParseFS(templateDir, components)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to parse templates '%s': %w", components, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var textTemplates = make(map[string]*ttemplate.Template, len(templates))
|
||||||
|
for _, tmpl := range templates {
|
||||||
|
rootTmplClone, err := rootTmpl.Clone()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("clone root template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
textTemplates[tmpl], err = prepareTemplate[*ttemplate.Template](tmpl, rootTmplClone, templateDir, "_text.tmpl")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse '%s': %w", tmpl, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return textTemplates, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func PrepareHTMLTemplates(templateDir fs.FS, templates []string) (map[string]*htemplate.Template, error) {
|
||||||
|
components := path.Join(templateComponentsDir, "*_html.tmpl")
|
||||||
|
rootTmpl, err := htemplate.ParseFS(templateDir, components)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to parse templates '%s': %w", components, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var htmlTemplates = make(map[string]*htemplate.Template, len(templates))
|
||||||
|
for _, tmpl := range templates {
|
||||||
|
rootTmplClone, err := rootTmpl.Clone()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("clone root template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
htmlTemplates[tmpl], err = prepareTemplate[*htemplate.Template](tmpl, rootTmplClone, templateDir, "_html.tmpl")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse '%s': %w", tmpl, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return htmlTemplates, nil
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ func CopyDirectory(srcDir, destDir string) error {
|
|||||||
srcFilePath := filepath.Join(srcDir, file.Name())
|
srcFilePath := filepath.Join(srcDir, file.Name())
|
||||||
destFilePath := filepath.Join(destDir, file.Name())
|
destFilePath := filepath.Join(destDir, file.Name())
|
||||||
|
|
||||||
err := copyFile(srcFilePath, destFilePath)
|
err := CopyFile(srcFilePath, destFilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -47,7 +47,7 @@ func CopyDirectory(srcDir, destDir string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func copyFile(srcFilePath, destFilePath string) error {
|
func CopyFile(srcFilePath, destFilePath string) error {
|
||||||
srcFile, err := os.Open(srcFilePath)
|
srcFile, err := os.Open(srcFilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -5,9 +5,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type PaginationResponse struct {
|
type PaginationResponse struct {
|
||||||
TotalPages int64 `json:"totalPages"`
|
TotalPages int64 `json:"totalPages"`
|
||||||
TotalItems int64 `json:"totalItems"`
|
TotalItems int64 `json:"totalItems"`
|
||||||
CurrentPage int `json:"currentPage"`
|
CurrentPage int `json:"currentPage"`
|
||||||
|
ItemsPerPage int `json:"itemsPerPage"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func Paginate(page int, pageSize int, db *gorm.DB, result interface{}) (PaginationResponse, error) {
|
func Paginate(page int, pageSize int, db *gorm.DB, result interface{}) (PaginationResponse, error) {
|
||||||
@@ -33,8 +34,9 @@ func Paginate(page int, pageSize int, db *gorm.DB, result interface{}) (Paginati
|
|||||||
}
|
}
|
||||||
|
|
||||||
return PaginationResponse{
|
return PaginationResponse{
|
||||||
TotalPages: (totalItems + int64(pageSize) - 1) / int64(pageSize),
|
TotalPages: (totalItems + int64(pageSize) - 1) / int64(pageSize),
|
||||||
TotalItems: totalItems,
|
TotalItems: totalItems,
|
||||||
CurrentPage: page,
|
CurrentPage: page,
|
||||||
|
ItemsPerPage: pageSize,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
2
backend/migrations/20240924202721_user_groups.down.sql
Normal file
2
backend/migrations/20240924202721_user_groups.down.sql
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
DROP TABLE user_groups;
|
||||||
|
DROP TABLE user_groups_users;
|
||||||
16
backend/migrations/20240924202721_user_groups.up.sql
Normal file
16
backend/migrations/20240924202721_user_groups.up.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE user_groups
|
||||||
|
(
|
||||||
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
created_at DATETIME,
|
||||||
|
friendly_name TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL UNIQUE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE user_groups_users
|
||||||
|
(
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
user_group_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, user_group_id),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (user_group_id) REFERENCES user_groups (id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE audit_logs DROP COLUMN country;
|
||||||
|
ALTER TABLE audit_logs DROP COLUMN city;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE audit_logs ADD COLUMN country TEXT;
|
||||||
|
ALTER TABLE audit_logs ADD COLUMN city TEXT;
|
||||||
1069
frontend/package-lock.json
generated
1069
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -12,46 +12,46 @@
|
|||||||
"format": "prettier --write ."
|
"format": "prettier --write ."
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.46.1",
|
"@playwright/test": "^1.47.2",
|
||||||
"@sveltejs/adapter-auto": "^3.2.4",
|
"@sveltejs/adapter-auto": "^3.2.5",
|
||||||
"@sveltejs/adapter-node": "^5.2.2",
|
"@sveltejs/adapter-node": "^5.2.5",
|
||||||
"@sveltejs/kit": "^2.5.24",
|
"@sveltejs/kit": "^2.6.1",
|
||||||
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
"@sveltejs/vite-plugin-svelte": "^3.1.2",
|
||||||
"@types/eslint": "^9.6.0",
|
"@types/eslint": "^9.6.1",
|
||||||
"@types/jsonwebtoken": "^9.0.6",
|
"@types/jsonwebtoken": "^9.0.7",
|
||||||
"@types/node": "^22.5.0",
|
"@types/node": "^22.7.4",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"cbor-js": "^0.1.0",
|
"cbor-js": "^0.1.0",
|
||||||
"eslint": "^9.9.1",
|
"eslint": "^9.11.1",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-svelte": "^2.40.0",
|
"eslint-plugin-svelte": "^2.44.1",
|
||||||
"globals": "^15.9.0",
|
"globals": "^15.10.0",
|
||||||
"postcss": "^8.4.41",
|
"postcss": "^8.4.47",
|
||||||
"prettier": "^3.3.3",
|
"prettier": "^3.3.3",
|
||||||
"prettier-plugin-svelte": "^3.2.6",
|
"prettier-plugin-svelte": "^3.2.7",
|
||||||
"prettier-plugin-tailwindcss": "^0.6.6",
|
"prettier-plugin-tailwindcss": "^0.6.8",
|
||||||
"svelte": "^5.0.0-next.1",
|
"svelte": "^5.0.0-next.262",
|
||||||
"svelte-check": "^3.8.6",
|
"svelte-check": "^4.0.4",
|
||||||
"tailwindcss": "^3.4.10",
|
"tailwindcss": "^3.4.13",
|
||||||
"tslib": "^2.7.0",
|
"tslib": "^2.7.0",
|
||||||
"typescript": "^5.5.4",
|
"typescript": "^5.6.2",
|
||||||
"typescript-eslint": "^8.2.0",
|
"typescript-eslint": "^8.8.0",
|
||||||
"vite": "^5.4.2"
|
"vite": "^5.4.8"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@simplewebauthn/browser": "^10.0.0",
|
"@simplewebauthn/browser": "^10.0.0",
|
||||||
"axios": "^1.7.5",
|
"axios": "^1.7.7",
|
||||||
"bits-ui": "^0.21.13",
|
"bits-ui": "^0.21.16",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"crypto": "^1.0.1",
|
"crypto": "^1.0.1",
|
||||||
"formsnap": "^1.0.1",
|
"formsnap": "^1.0.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"lucide-svelte": "^0.435.0",
|
"lucide-svelte": "^0.447.0",
|
||||||
"mode-watcher": "^0.4.1",
|
"mode-watcher": "^0.4.1",
|
||||||
"svelte-sonner": "^0.3.27",
|
"svelte-sonner": "^0.3.28",
|
||||||
"sveltekit-superforms": "^2.17.0",
|
"sveltekit-superforms": "^2.19.0",
|
||||||
"tailwind-merge": "^2.5.2",
|
"tailwind-merge": "^2.5.3",
|
||||||
"tailwind-variants": "^0.2.1",
|
"tailwind-variants": "^0.2.1",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.23.8"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,16 +97,4 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
src: url('/fonts/PlayfairDisplay-Bold.woff') format('woff');
|
src: url('/fonts/PlayfairDisplay-Bold.woff') format('woff');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@layer components {
|
|
||||||
.application-images-grid {
|
|
||||||
@apply flex flex-wrap justify-between gap-x-5 gap-y-8;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1127px) {
|
|
||||||
.application-images-grid {
|
|
||||||
justify-content: flex-start;
|
|
||||||
@apply gap-x-20;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
154
frontend/src/lib/components/advanced-table.svelte
Normal file
154
frontend/src/lib/components/advanced-table.svelte
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
<script lang="ts" generics="T extends {id:string}">
|
||||||
|
import Checkbox from '$lib/components/ui/checkbox/checkbox.svelte';
|
||||||
|
import { Input } from '$lib/components/ui/input/index.js';
|
||||||
|
import * as Pagination from '$lib/components/ui/pagination';
|
||||||
|
import * as Select from '$lib/components/ui/select';
|
||||||
|
import * as Table from '$lib/components/ui/table/index.js';
|
||||||
|
import type { Paginated } from '$lib/types/pagination.type';
|
||||||
|
import { debounced } from '$lib/utils/debounce-util';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
items,
|
||||||
|
selectedIds = $bindable(),
|
||||||
|
fetchItems,
|
||||||
|
columns,
|
||||||
|
rows
|
||||||
|
}: {
|
||||||
|
items: Paginated<T>;
|
||||||
|
selectedIds?: string[];
|
||||||
|
fetchItems: (search: string, page: number, limit: number) => Promise<Paginated<T>>;
|
||||||
|
columns: (string | { label: string; hidden?: boolean })[];
|
||||||
|
rows: Snippet<[{ item: T }]>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let availablePageSizes: number[] = [10, 20, 50, 100];
|
||||||
|
|
||||||
|
let allChecked = $derived.by(() => {
|
||||||
|
if (!selectedIds || items.data.length === 0) return false;
|
||||||
|
for (const item of items.data) {
|
||||||
|
if (!selectedIds.includes(item.id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSearch = debounced(async (searchValue: string) => {
|
||||||
|
items = await fetchItems(searchValue, 1, items.pagination.itemsPerPage);
|
||||||
|
}, 300);
|
||||||
|
|
||||||
|
async function onAllCheck(checked: boolean) {
|
||||||
|
if (checked) {
|
||||||
|
selectedIds = items.data.map((item) => item.id);
|
||||||
|
} else {
|
||||||
|
selectedIds = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCheck(checked: boolean, id: string) {
|
||||||
|
if (!selectedIds) return;
|
||||||
|
if (checked) {
|
||||||
|
selectedIds = [...selectedIds, id];
|
||||||
|
} else {
|
||||||
|
selectedIds = selectedIds.filter((selectedId) => selectedId !== id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPageChange(page: number) {
|
||||||
|
items = await fetchItems('', page, items.pagination.itemsPerPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPageSizeChange(size: number) {
|
||||||
|
items = await fetchItems('', 1, size);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="w-full">
|
||||||
|
<Input
|
||||||
|
class="mb-4 max-w-sm"
|
||||||
|
placeholder={'Search...'}
|
||||||
|
type="text"
|
||||||
|
oninput={(e) => onSearch((e.target as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
<Table.Root>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
{#if selectedIds}
|
||||||
|
<Table.Head>
|
||||||
|
<Checkbox checked={allChecked} onCheckedChange={(c) => onAllCheck(c as boolean)} />
|
||||||
|
</Table.Head>
|
||||||
|
{/if}
|
||||||
|
{#each columns as column}
|
||||||
|
{#if typeof column === 'string'}
|
||||||
|
<Table.Head>{column}</Table.Head>
|
||||||
|
{:else}
|
||||||
|
<Table.Head class={column.hidden ? 'sr-only' : ''}>{column.label}</Table.Head>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{#each items.data as item}
|
||||||
|
<Table.Row class={selectedIds?.includes(item.id) ? 'bg-muted/20' : ''}>
|
||||||
|
{#if selectedIds}
|
||||||
|
<Table.Cell>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedIds.includes(item.id)}
|
||||||
|
onCheckedChange={(c) => onCheck(c as boolean, item.id)}
|
||||||
|
/>
|
||||||
|
</Table.Cell>
|
||||||
|
{/if}
|
||||||
|
{@render rows({ item })}
|
||||||
|
</Table.Row>
|
||||||
|
{/each}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
<div class="mt-5 flex items-center justify-between space-x-2">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<p class="text-sm font-medium">Items per page</p>
|
||||||
|
<Select.Root
|
||||||
|
selected={{
|
||||||
|
label: items.pagination.itemsPerPage.toString(),
|
||||||
|
value: items.pagination.itemsPerPage
|
||||||
|
}}
|
||||||
|
onSelectedChange={(v) => onPageSizeChange(v?.value as number)}
|
||||||
|
>
|
||||||
|
<Select.Trigger class="h-9 w-[80px]">
|
||||||
|
<Select.Value>{items.pagination.itemsPerPage}</Select.Value>
|
||||||
|
</Select.Trigger>
|
||||||
|
<Select.Content>
|
||||||
|
{#each availablePageSizes as size}
|
||||||
|
<Select.Item value={size}>{size}</Select.Item>
|
||||||
|
{/each}
|
||||||
|
</Select.Content>
|
||||||
|
</Select.Root>
|
||||||
|
</div>
|
||||||
|
<Pagination.Root
|
||||||
|
class="mx-0 w-auto"
|
||||||
|
count={items.pagination.totalItems}
|
||||||
|
perPage={items.pagination.itemsPerPage}
|
||||||
|
{onPageChange}
|
||||||
|
page={items.pagination.currentPage}
|
||||||
|
let:pages
|
||||||
|
>
|
||||||
|
<Pagination.Content class="flex justify-end">
|
||||||
|
<Pagination.Item>
|
||||||
|
<Pagination.PrevButton />
|
||||||
|
</Pagination.Item>
|
||||||
|
{#each pages as page (page.key)}
|
||||||
|
{#if page.type !== 'ellipsis'}
|
||||||
|
<Pagination.Item>
|
||||||
|
<Pagination.Link {page} isActive={items.pagination.currentPage === page.value}>
|
||||||
|
{page.value}
|
||||||
|
</Pagination.Link>
|
||||||
|
</Pagination.Item>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
<Pagination.Item>
|
||||||
|
<Pagination.NextButton />
|
||||||
|
</Pagination.Item>
|
||||||
|
</Pagination.Content>
|
||||||
|
</Pagination.Root>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
41
frontend/src/lib/components/copy-to-clipboard.svelte
Normal file
41
frontend/src/lib/components/copy-to-clipboard.svelte
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||||
|
import { LucideCheck } from 'lucide-svelte';
|
||||||
|
import type { Snippet } from 'svelte';
|
||||||
|
|
||||||
|
let { value, children }: { value: string; children: Snippet } = $props();
|
||||||
|
|
||||||
|
let open = $state(false);
|
||||||
|
let copied = $state(false);
|
||||||
|
|
||||||
|
function onClick() {
|
||||||
|
open = true;
|
||||||
|
copyToClipboard();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOpenChange(state: boolean) {
|
||||||
|
open = state;
|
||||||
|
if (!state) {
|
||||||
|
copied = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyToClipboard() {
|
||||||
|
navigator.clipboard.writeText(value);
|
||||||
|
copied = true;
|
||||||
|
setTimeout(() => onOpenChange(false), 1000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button onclick={onClick}>
|
||||||
|
<Tooltip.Root closeOnPointerDown={false} {onOpenChange} {open}>
|
||||||
|
<Tooltip.Trigger>{@render children()}</Tooltip.Trigger>
|
||||||
|
<Tooltip.Content onclick={copyToClipboard}>
|
||||||
|
{#if copied}
|
||||||
|
<span class="flex items-center"><LucideCheck class="mr-1 h-4 w-4" /> Copied</span>
|
||||||
|
{:else}
|
||||||
|
<span>Click to copy</span>
|
||||||
|
{/if}
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Root>
|
||||||
|
</button>
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
import type { FormInput } from '$lib/utils/form-util';
|
import type { FormInput } from '$lib/utils/form-util';
|
||||||
import type { Snippet } from 'svelte';
|
import type { Snippet } from 'svelte';
|
||||||
import type { HTMLAttributes } from 'svelte/elements';
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
import { Input } from './ui/input';
|
import { Input, type FormInputEvent } from './ui/input';
|
||||||
|
|
||||||
let {
|
let {
|
||||||
input = $bindable(),
|
input = $bindable(),
|
||||||
@@ -12,6 +12,7 @@
|
|||||||
disabled = false,
|
disabled = false,
|
||||||
type = 'text',
|
type = 'text',
|
||||||
children,
|
children,
|
||||||
|
onInput,
|
||||||
...restProps
|
...restProps
|
||||||
}: HTMLAttributes<HTMLDivElement> & {
|
}: HTMLAttributes<HTMLDivElement> & {
|
||||||
input?: FormInput<string | boolean | number>;
|
input?: FormInput<string | boolean | number>;
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
description?: string;
|
description?: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
type?: 'text' | 'password' | 'email' | 'number' | 'checkbox';
|
type?: 'text' | 'password' | 'email' | 'number' | 'checkbox';
|
||||||
|
onInput?: (e: FormInputEvent) => void;
|
||||||
children?: Snippet;
|
children?: Snippet;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
@@ -34,7 +36,7 @@
|
|||||||
{#if children}
|
{#if children}
|
||||||
{@render children()}
|
{@render children()}
|
||||||
{:else if input}
|
{:else if input}
|
||||||
<Input {id} {type} bind:value={input.value} {disabled} />
|
<Input {id} {type} bind:value={input.value} {disabled} on:input={(e) => onInput?.(e)} />
|
||||||
{/if}
|
{/if}
|
||||||
{#if input?.error}
|
{#if input?.error}
|
||||||
<p class="mt-1 text-sm text-red-500">{input.error}</p>
|
<p class="mt-1 text-sm text-red-500">{input.error}</p>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||||
import WebAuthnService from '$lib/services/webauthn-service';
|
import WebAuthnService from '$lib/services/webauthn-service';
|
||||||
import userStore from '$lib/stores/user-store';
|
import userStore from '$lib/stores/user-store';
|
||||||
|
import { createSHA256hash } from '$lib/utils/crypto-util';
|
||||||
import { LucideLogOut, LucideUser } from 'lucide-svelte';
|
import { LucideLogOut, LucideUser } from 'lucide-svelte';
|
||||||
|
|
||||||
const webauthnService = new WebAuthnService();
|
const webauthnService = new WebAuthnService();
|
||||||
@@ -11,6 +12,13 @@
|
|||||||
($userStore!.firstName.charAt(0) + $userStore!.lastName?.charAt(0)).toUpperCase()
|
($userStore!.firstName.charAt(0) + $userStore!.lastName?.charAt(0)).toUpperCase()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let gravatarURL: string | undefined = $state();
|
||||||
|
if ($userStore) {
|
||||||
|
createSHA256hash($userStore.email).then((email) => {
|
||||||
|
gravatarURL = `https://www.gravatar.com/avatar/${email}?d=404`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
await webauthnService.logout();
|
await webauthnService.logout();
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
@@ -19,7 +27,8 @@
|
|||||||
|
|
||||||
<DropdownMenu.Root>
|
<DropdownMenu.Root>
|
||||||
<DropdownMenu.Trigger
|
<DropdownMenu.Trigger
|
||||||
><Avatar.Root>
|
><Avatar.Root class="h-9 w-9">
|
||||||
|
<Avatar.Image src={gravatarURL} />
|
||||||
<Avatar.Fallback>{initials}</Avatar.Fallback>
|
<Avatar.Fallback>{initials}</Avatar.Fallback>
|
||||||
</Avatar.Root></DropdownMenu.Trigger
|
</Avatar.Root></DropdownMenu.Trigger
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -12,7 +12,11 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class=" w-full {isAuthPage ? 'absolute top-0 z-10 mt-4' : 'border-b'}">
|
<div class=" w-full {isAuthPage ? 'absolute top-0 z-10 mt-4' : 'border-b'}">
|
||||||
<div class="mx-auto flex w-full max-w-[1640px] items-center justify-between px-4 md:px-10">
|
<div
|
||||||
|
class="{!isAuthPage
|
||||||
|
? 'max-w-[1640px]'
|
||||||
|
: ''} mx-auto flex w-full items-center justify-between px-4 md:px-10"
|
||||||
|
>
|
||||||
<div class="flex h-16 items-center">
|
<div class="flex h-16 items-center">
|
||||||
{#if !isAuthPage}
|
{#if !isAuthPage}
|
||||||
<Logo class="mr-3 h-10 w-10" />
|
<Logo class="mr-3 h-10 w-10" />
|
||||||
|
|||||||
@@ -1 +1,10 @@
|
|||||||
<img class={$$restProps.class} src="/api/application-configuration/logo" alt="Logo" />
|
<script lang="ts">
|
||||||
|
import { mode } from 'mode-watcher';
|
||||||
|
import type { HTMLAttributes } from 'svelte/elements';
|
||||||
|
|
||||||
|
let { ...props }: HTMLAttributes<HTMLImageElement> = $props();
|
||||||
|
|
||||||
|
const isDarkMode = $derived($mode === 'dark');
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<img {...props} src="/api/application-configuration/logo?light={!isDarkMode}" alt="Logo" />
|
||||||
|
|||||||
34
frontend/src/lib/components/ui/select/index.ts
Normal file
34
frontend/src/lib/components/ui/select/index.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Select as SelectPrimitive } from "bits-ui";
|
||||||
|
|
||||||
|
import Label from "./select-label.svelte";
|
||||||
|
import Item from "./select-item.svelte";
|
||||||
|
import Content from "./select-content.svelte";
|
||||||
|
import Trigger from "./select-trigger.svelte";
|
||||||
|
import Separator from "./select-separator.svelte";
|
||||||
|
|
||||||
|
const Root = SelectPrimitive.Root;
|
||||||
|
const Group = SelectPrimitive.Group;
|
||||||
|
const Input = SelectPrimitive.Input;
|
||||||
|
const Value = SelectPrimitive.Value;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Group,
|
||||||
|
Input,
|
||||||
|
Label,
|
||||||
|
Item,
|
||||||
|
Value,
|
||||||
|
Content,
|
||||||
|
Trigger,
|
||||||
|
Separator,
|
||||||
|
//
|
||||||
|
Root as Select,
|
||||||
|
Group as SelectGroup,
|
||||||
|
Input as SelectInput,
|
||||||
|
Label as SelectLabel,
|
||||||
|
Item as SelectItem,
|
||||||
|
Value as SelectValue,
|
||||||
|
Content as SelectContent,
|
||||||
|
Trigger as SelectTrigger,
|
||||||
|
Separator as SelectSeparator,
|
||||||
|
};
|
||||||
39
frontend/src/lib/components/ui/select/select-content.svelte
Normal file
39
frontend/src/lib/components/ui/select/select-content.svelte
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Select as SelectPrimitive } from "bits-ui";
|
||||||
|
import { scale } from "svelte/transition";
|
||||||
|
import { cn, flyAndScale } from "$lib/utils/style.js";
|
||||||
|
|
||||||
|
type $$Props = SelectPrimitive.ContentProps;
|
||||||
|
type $$Events = SelectPrimitive.ContentEvents;
|
||||||
|
|
||||||
|
export let sideOffset: $$Props["sideOffset"] = 4;
|
||||||
|
export let inTransition: $$Props["inTransition"] = flyAndScale;
|
||||||
|
export let inTransitionConfig: $$Props["inTransitionConfig"] = undefined;
|
||||||
|
export let outTransition: $$Props["outTransition"] = scale;
|
||||||
|
export let outTransitionConfig: $$Props["outTransitionConfig"] = {
|
||||||
|
start: 0.95,
|
||||||
|
opacity: 0,
|
||||||
|
duration: 50,
|
||||||
|
};
|
||||||
|
|
||||||
|
let className: $$Props["class"] = undefined;
|
||||||
|
export { className as class };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
{inTransition}
|
||||||
|
{inTransitionConfig}
|
||||||
|
{outTransition}
|
||||||
|
{outTransitionConfig}
|
||||||
|
{sideOffset}
|
||||||
|
class={cn(
|
||||||
|
"bg-popover text-popover-foreground relative z-50 min-w-[8rem] overflow-hidden rounded-md border shadow-md outline-none",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...$$restProps}
|
||||||
|
on:keydown
|
||||||
|
>
|
||||||
|
<div class="w-full p-1">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</SelectPrimitive.Content>
|
||||||
40
frontend/src/lib/components/ui/select/select-item.svelte
Normal file
40
frontend/src/lib/components/ui/select/select-item.svelte
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Check from "lucide-svelte/icons/check";
|
||||||
|
import { Select as SelectPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils/style.js";
|
||||||
|
|
||||||
|
type $$Props = SelectPrimitive.ItemProps;
|
||||||
|
type $$Events = SelectPrimitive.ItemEvents;
|
||||||
|
|
||||||
|
let className: $$Props["class"] = undefined;
|
||||||
|
export let value: $$Props["value"];
|
||||||
|
export let label: $$Props["label"] = undefined;
|
||||||
|
export let disabled: $$Props["disabled"] = undefined;
|
||||||
|
export { className as class };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
{value}
|
||||||
|
{disabled}
|
||||||
|
{label}
|
||||||
|
class={cn(
|
||||||
|
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...$$restProps}
|
||||||
|
on:click
|
||||||
|
on:keydown
|
||||||
|
on:focusin
|
||||||
|
on:focusout
|
||||||
|
on:pointerleave
|
||||||
|
on:pointermove
|
||||||
|
>
|
||||||
|
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check class="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<slot>
|
||||||
|
{label || value}
|
||||||
|
</slot>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
16
frontend/src/lib/components/ui/select/select-label.svelte
Normal file
16
frontend/src/lib/components/ui/select/select-label.svelte
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Select as SelectPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils/style.js";
|
||||||
|
|
||||||
|
type $$Props = SelectPrimitive.LabelProps;
|
||||||
|
|
||||||
|
let className: $$Props["class"] = undefined;
|
||||||
|
export { className as class };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
class={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
|
||||||
|
{...$$restProps}
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</SelectPrimitive.Label>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Select as SelectPrimitive } from "bits-ui";
|
||||||
|
import { cn } from "$lib/utils/style.js";
|
||||||
|
|
||||||
|
type $$Props = SelectPrimitive.SeparatorProps;
|
||||||
|
|
||||||
|
let className: $$Props["class"] = undefined;
|
||||||
|
export { className as class };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SelectPrimitive.Separator class={cn("bg-muted -mx-1 my-1 h-px", className)} {...$$restProps} />
|
||||||
27
frontend/src/lib/components/ui/select/select-trigger.svelte
Normal file
27
frontend/src/lib/components/ui/select/select-trigger.svelte
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Select as SelectPrimitive } from "bits-ui";
|
||||||
|
import ChevronDown from "lucide-svelte/icons/chevron-down";
|
||||||
|
import { cn } from "$lib/utils/style.js";
|
||||||
|
|
||||||
|
type $$Props = SelectPrimitive.TriggerProps;
|
||||||
|
type $$Events = SelectPrimitive.TriggerEvents;
|
||||||
|
|
||||||
|
let className: $$Props["class"] = undefined;
|
||||||
|
export { className as class };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
class={cn(
|
||||||
|
"border-input bg-background ring-offset-background focus-visible:ring-ring aria-[invalid]:border-destructive data-[placeholder]:[&>span]:text-muted-foreground flex h-10 w-full items-center justify-between rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...$$restProps}
|
||||||
|
let:builder
|
||||||
|
on:click
|
||||||
|
on:keydown
|
||||||
|
>
|
||||||
|
<slot {builder} />
|
||||||
|
<div>
|
||||||
|
<ChevronDown class="h-4 w-4 opacity-50" />
|
||||||
|
</div>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
15
frontend/src/lib/components/ui/tooltip/index.ts
Normal file
15
frontend/src/lib/components/ui/tooltip/index.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { Tooltip as TooltipPrimitive } from "bits-ui";
|
||||||
|
import Content from "./tooltip-content.svelte";
|
||||||
|
|
||||||
|
const Root = TooltipPrimitive.Root;
|
||||||
|
const Trigger = TooltipPrimitive.Trigger;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Root,
|
||||||
|
Trigger,
|
||||||
|
Content,
|
||||||
|
//
|
||||||
|
Root as Tooltip,
|
||||||
|
Content as TooltipContent,
|
||||||
|
Trigger as TooltipTrigger,
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Tooltip as TooltipPrimitive } from "bits-ui";
|
||||||
|
import { cn, flyAndScale } from "$lib/utils/style.js";
|
||||||
|
|
||||||
|
type $$Props = TooltipPrimitive.ContentProps;
|
||||||
|
|
||||||
|
let className: $$Props["class"] = undefined;
|
||||||
|
export let sideOffset: $$Props["sideOffset"] = 4;
|
||||||
|
export let transition: $$Props["transition"] = flyAndScale;
|
||||||
|
export let transitionConfig: $$Props["transitionConfig"] = {
|
||||||
|
y: 8,
|
||||||
|
duration: 150,
|
||||||
|
};
|
||||||
|
export { className as class };
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TooltipPrimitive.Content
|
||||||
|
{transition}
|
||||||
|
{transitionConfig}
|
||||||
|
{sideOffset}
|
||||||
|
class={cn(
|
||||||
|
"bg-popover text-popover-foreground z-50 overflow-hidden rounded-md border px-3 py-1.5 text-sm shadow-md",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...$$restProps}
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</TooltipPrimitive.Content>
|
||||||
@@ -13,7 +13,7 @@ abstract class APIService {
|
|||||||
if (browser) {
|
if (browser) {
|
||||||
this.api.defaults.baseURL = '/api';
|
this.api.defaults.baseURL = '/api';
|
||||||
} else {
|
} else {
|
||||||
this.api.defaults.baseURL = process?.env?.INTERNAL_BACKEND_URL + '/api';
|
this.api.defaults.baseURL = process!.env!.INTERNAL_BACKEND_URL + '/api';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import type {
|
import type { AllAppConfig, AppConfigRawResponse } from '$lib/types/application-configuration';
|
||||||
AllAppConfig,
|
|
||||||
AppConfigRawResponse
|
|
||||||
} from '$lib/types/application-configuration';
|
|
||||||
import APIService from './api-service';
|
import APIService from './api-service';
|
||||||
|
|
||||||
export default class AppConfigService extends APIService {
|
export default class AppConfigService extends APIService {
|
||||||
@@ -33,11 +30,13 @@ export default class AppConfigService extends APIService {
|
|||||||
await this.api.put(`/application-configuration/favicon`, formData);
|
await this.api.put(`/application-configuration/favicon`, formData);
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateLogo(logo: File) {
|
async updateLogo(logo: File, light = true) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('file', logo!);
|
formData.append('file', logo!);
|
||||||
|
|
||||||
await this.api.put(`/application-configuration/logo`, formData);
|
await this.api.put(`/application-configuration/logo`, formData, {
|
||||||
|
params: { light }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateBackgroundImage(backgroundImage: File) {
|
async updateBackgroundImage(backgroundImage: File) {
|
||||||
|
|||||||
@@ -4,14 +4,8 @@ import APIService from './api-service';
|
|||||||
|
|
||||||
class AuditLogService extends APIService {
|
class AuditLogService extends APIService {
|
||||||
async list(pagination?: PaginationRequest) {
|
async list(pagination?: PaginationRequest) {
|
||||||
const page = pagination?.page || 1;
|
|
||||||
const limit = pagination?.limit || 10;
|
|
||||||
|
|
||||||
const res = await this.api.get('/audit-logs', {
|
const res = await this.api.get('/audit-logs', {
|
||||||
params: {
|
params: pagination
|
||||||
page,
|
|
||||||
limit
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
return res.data as Paginated<AuditLog>;
|
return res.data as Paginated<AuditLog>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { Paginated, PaginationRequest } from '$lib/types/pagination.type';
|
|||||||
import APIService from './api-service';
|
import APIService from './api-service';
|
||||||
|
|
||||||
class OidcService extends APIService {
|
class OidcService extends APIService {
|
||||||
async authorize(clientId: string, scope: string, callbackURL : string, nonce?: string) {
|
async authorize(clientId: string, scope: string, callbackURL: string, nonce?: string) {
|
||||||
const res = await this.api.post('/oidc/authorize', {
|
const res = await this.api.post('/oidc/authorize', {
|
||||||
scope,
|
scope,
|
||||||
nonce,
|
nonce,
|
||||||
@@ -26,14 +26,10 @@ class OidcService extends APIService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listClients(search?: string, pagination?: PaginationRequest) {
|
async listClients(search?: string, pagination?: PaginationRequest) {
|
||||||
const page = pagination?.page || 1;
|
|
||||||
const limit = pagination?.limit || 10;
|
|
||||||
|
|
||||||
const res = await this.api.get('/oidc/clients', {
|
const res = await this.api.get('/oidc/clients', {
|
||||||
params: {
|
params: {
|
||||||
search,
|
search,
|
||||||
page,
|
...pagination
|
||||||
limit
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return res.data as Paginated<OidcClient>;
|
return res.data as Paginated<OidcClient>;
|
||||||
|
|||||||
43
frontend/src/lib/services/user-group-service.ts
Normal file
43
frontend/src/lib/services/user-group-service.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import type { Paginated, PaginationRequest } from '$lib/types/pagination.type';
|
||||||
|
import type {
|
||||||
|
UserGroupCreate,
|
||||||
|
UserGroupWithUserCount,
|
||||||
|
UserGroupWithUsers
|
||||||
|
} from '$lib/types/user-group.type';
|
||||||
|
import APIService from './api-service';
|
||||||
|
|
||||||
|
export default class UserGroupService extends APIService {
|
||||||
|
async list(search?: string, pagination?: PaginationRequest) {
|
||||||
|
const res = await this.api.get('/user-groups', {
|
||||||
|
params: {
|
||||||
|
search,
|
||||||
|
...pagination
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return res.data as Paginated<UserGroupWithUserCount>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string) {
|
||||||
|
const res = await this.api.get(`/user-groups/${id}`);
|
||||||
|
return res.data as UserGroupWithUsers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(user: UserGroupCreate) {
|
||||||
|
const res = await this.api.post('/user-groups', user);
|
||||||
|
return res.data as UserGroupWithUsers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, user: UserGroupCreate) {
|
||||||
|
const res = await this.api.put(`/user-groups/${id}`, user);
|
||||||
|
return res.data as UserGroupWithUsers;
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: string) {
|
||||||
|
await this.api.delete(`/user-groups/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateUsers(id: string, userIds: string[]) {
|
||||||
|
const res = await this.api.put(`/user-groups/${id}/users`, { userIds });
|
||||||
|
return res.data as UserGroupWithUsers;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,14 +4,10 @@ import APIService from './api-service';
|
|||||||
|
|
||||||
export default class UserService extends APIService {
|
export default class UserService extends APIService {
|
||||||
async list(search?: string, pagination?: PaginationRequest) {
|
async list(search?: string, pagination?: PaginationRequest) {
|
||||||
const page = pagination?.page || 1;
|
|
||||||
const limit = pagination?.limit || 10;
|
|
||||||
|
|
||||||
const res = await this.api.get('/users', {
|
const res = await this.api.get('/users', {
|
||||||
params: {
|
params: {
|
||||||
search,
|
search,
|
||||||
page,
|
...pagination
|
||||||
limit
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return res.data as Paginated<User>;
|
return res.data as Paginated<User>;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ export type AuditLog = {
|
|||||||
id: string;
|
id: string;
|
||||||
event: string;
|
event: string;
|
||||||
ipAddress: string;
|
ipAddress: string;
|
||||||
|
country?: string;
|
||||||
|
city?: string;
|
||||||
device: string;
|
device: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
data: any;
|
data: any;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export type PaginationResponse = {
|
|||||||
totalPages: number;
|
totalPages: number;
|
||||||
totalItems: number;
|
totalItems: number;
|
||||||
currentPage: number;
|
currentPage: number;
|
||||||
|
itemsPerPage: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Paginated<T> = {
|
export type Paginated<T> = {
|
||||||
|
|||||||
18
frontend/src/lib/types/user-group.type.ts
Normal file
18
frontend/src/lib/types/user-group.type.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import type { User } from './user.type';
|
||||||
|
|
||||||
|
export type UserGroup = {
|
||||||
|
id: string;
|
||||||
|
friendlyName: string;
|
||||||
|
name: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserGroupWithUsers = UserGroup & {
|
||||||
|
users: User[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserGroupWithUserCount = UserGroup & {
|
||||||
|
userCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UserGroupCreate = Pick<UserGroup, 'friendlyName' | 'name'>;
|
||||||
7
frontend/src/lib/utils/crypto-util.ts
Normal file
7
frontend/src/lib/utils/crypto-util.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export async function createSHA256hash(input: string) {
|
||||||
|
const msgUint8 = new TextEncoder().encode(input); // encode as (utf-8) Uint8Array
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', msgUint8); // hash the message
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer)); // convert buffer to byte array
|
||||||
|
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); // convert bytes to hex string
|
||||||
|
return hashHex;
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
|
import { getWebauthnErrorMessage } from '$lib/utils/error-util';
|
||||||
import { startAuthentication } from '@simplewebauthn/browser';
|
import { startAuthentication } from '@simplewebauthn/browser';
|
||||||
import { AxiosError } from 'axios';
|
import { AxiosError } from 'axios';
|
||||||
import { LucideMail, LucideUser } from 'lucide-svelte';
|
import { LucideMail, LucideUser, LucideUsers } from 'lucide-svelte';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import type { PageData } from './$types';
|
import type { PageData } from './$types';
|
||||||
import ClientProviderImages from './components/client-provider-images.svelte';
|
import ClientProviderImages from './components/client-provider-images.svelte';
|
||||||
@@ -113,6 +113,13 @@
|
|||||||
description="View your profile information"
|
description="View your profile information"
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if scope!.includes('groups')}
|
||||||
|
<ScopeItem
|
||||||
|
icon={LucideUsers}
|
||||||
|
name="Groups"
|
||||||
|
description="View the groups you are a member of"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
links = [
|
links = [
|
||||||
...links,
|
...links,
|
||||||
{ href: '/settings/admin/users', label: 'Users' },
|
{ href: '/settings/admin/users', label: 'Users' },
|
||||||
|
{ href: '/settings/admin/user-groups', label: 'User Groups' },
|
||||||
{ href: '/settings/admin/oidc-clients', label: 'OIDC Clients' },
|
{ href: '/settings/admin/oidc-clients', label: 'OIDC Clients' },
|
||||||
{ href: '/settings/admin/application-configuration', label: 'Application Configuration' }
|
{ href: '/settings/admin/application-configuration', label: 'Application Configuration' }
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -38,10 +38,10 @@
|
|||||||
<form onsubmit={onSubmit}>
|
<form onsubmit={onSubmit}>
|
||||||
<div class="flex flex-col gap-3 sm:flex-row">
|
<div class="flex flex-col gap-3 sm:flex-row">
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<FormInput label="Firstname" bind:input={$inputs.firstName} />
|
<FormInput label="First name" bind:input={$inputs.firstName} />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<FormInput label="Lastname" bind:input={$inputs.lastName} />
|
<FormInput label="Last name" bind:input={$inputs.lastName} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 flex flex-col gap-3 sm:flex-row">
|
<div class="mt-3 flex flex-col gap-3 sm:flex-row">
|
||||||
|
|||||||
@@ -28,17 +28,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function updateImages(
|
async function updateImages(
|
||||||
logo: File | null,
|
logoLight: File | null,
|
||||||
|
logoDark: File | null,
|
||||||
backgroundImage: File | null,
|
backgroundImage: File | null,
|
||||||
favicon: File | null
|
favicon: File | null
|
||||||
) {
|
) {
|
||||||
const faviconPromise = favicon ? appConfigService.updateFavicon(favicon) : Promise.resolve();
|
const faviconPromise = favicon ? appConfigService.updateFavicon(favicon) : Promise.resolve();
|
||||||
const logoPromise = logo ? appConfigService.updateLogo(logo) : Promise.resolve();
|
const lightLogoPromise = logoLight ? appConfigService.updateLogo(logoLight, true) : Promise.resolve();
|
||||||
|
const darkLogoPromise = logoDark ? appConfigService.updateLogo(logoDark, false) : Promise.resolve();
|
||||||
const backgroundImagePromise = backgroundImage
|
const backgroundImagePromise = backgroundImage
|
||||||
? appConfigService.updateBackgroundImage(backgroundImage)
|
? appConfigService.updateBackgroundImage(backgroundImage)
|
||||||
: Promise.resolve();
|
: Promise.resolve();
|
||||||
|
|
||||||
await Promise.all([logoPromise, backgroundImagePromise, faviconPromise])
|
await Promise.all([lightLogoPromise, darkLogoPromise, backgroundImagePromise, faviconPromise])
|
||||||
.then(() => toast.success('Images updated successfully'))
|
.then(() => toast.success('Images updated successfully'))
|
||||||
.catch(axiosErrorToast);
|
.catch(axiosErrorToast);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,10 @@
|
|||||||
id,
|
id,
|
||||||
imageClass,
|
imageClass,
|
||||||
label,
|
label,
|
||||||
image = $bindable<File | null>(null),
|
image = $bindable(),
|
||||||
imageURL,
|
imageURL,
|
||||||
accept = 'image/png, image/jpeg, image/svg+xml',
|
accept = 'image/png, image/jpeg, image/svg+xml',
|
||||||
|
forceColorScheme,
|
||||||
...restProps
|
...restProps
|
||||||
}: HTMLAttributes<HTMLDivElement> & {
|
}: HTMLAttributes<HTMLDivElement> & {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
label: string;
|
label: string;
|
||||||
image: File | null;
|
image: File | null;
|
||||||
imageURL: string;
|
imageURL: string;
|
||||||
|
forceColorScheme?: 'light' | 'dark';
|
||||||
accept?: string;
|
accept?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
@@ -37,10 +39,16 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div {...restProps}>
|
<div class="flex flex-col items-start md:flex-row md:items-center" {...restProps}>
|
||||||
<Label for={id}>{label}</Label>
|
<Label class="w-52" for={id}>{label}</Label>
|
||||||
<FileInput {id} variant="secondary" {accept} onchange={onImageChange}>
|
<FileInput {id} variant="secondary" {accept} onchange={onImageChange}>
|
||||||
<div class="bg-muted group relative flex items-center rounded">
|
<div
|
||||||
|
class="{forceColorScheme === 'light'
|
||||||
|
? 'bg-[#F1F1F5]'
|
||||||
|
: forceColorScheme === 'dark'
|
||||||
|
? 'bg-[#27272A]'
|
||||||
|
: 'bg-muted'} group relative flex items-center rounded"
|
||||||
|
>
|
||||||
<img
|
<img
|
||||||
class={cn(
|
class={cn(
|
||||||
'h-full w-full rounded object-cover p-3 transition-opacity duration-200 group-hover:opacity-10',
|
'h-full w-full rounded object-cover p-3 transition-opacity duration-200 group-hover:opacity-10',
|
||||||
|
|||||||
@@ -5,15 +5,21 @@
|
|||||||
let {
|
let {
|
||||||
callback
|
callback
|
||||||
}: {
|
}: {
|
||||||
callback: (logo: File | null, backgroundImage: File | null, favicon: File | null) => void;
|
callback: (
|
||||||
|
logoLight: File | null,
|
||||||
|
logoDark: File | null,
|
||||||
|
backgroundImage: File | null,
|
||||||
|
favicon: File | null
|
||||||
|
) => void;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let logo = $state<File | null>(null);
|
let logoLight = $state<File | null>(null);
|
||||||
|
let logoDark = $state<File | null>(null);
|
||||||
let backgroundImage = $state<File | null>(null);
|
let backgroundImage = $state<File | null>(null);
|
||||||
let favicon = $state<File | null>(null);
|
let favicon = $state<File | null>(null);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="application-images-grid">
|
<div class="flex flex-col gap-8">
|
||||||
<ApplicationImage
|
<ApplicationImage
|
||||||
id="favicon"
|
id="favicon"
|
||||||
imageClass="h-14 w-14 p-2"
|
imageClass="h-14 w-14 p-2"
|
||||||
@@ -23,15 +29,23 @@
|
|||||||
accept="image/x-icon"
|
accept="image/x-icon"
|
||||||
/>
|
/>
|
||||||
<ApplicationImage
|
<ApplicationImage
|
||||||
id="logo"
|
id="logo-light"
|
||||||
imageClass="h-32 w-32"
|
imageClass="h-32 w-32"
|
||||||
label="Logo"
|
label="Light Mode Logo"
|
||||||
bind:image={logo}
|
bind:image={logoLight}
|
||||||
imageURL="/api/application-configuration/logo"
|
imageURL="/api/application-configuration/logo?light=true"
|
||||||
|
forceColorScheme="light"
|
||||||
|
/>
|
||||||
|
<ApplicationImage
|
||||||
|
id="logo-dark"
|
||||||
|
imageClass="h-32 w-32"
|
||||||
|
label="Dark Mode Logo"
|
||||||
|
bind:image={logoDark}
|
||||||
|
imageURL="/api/application-configuration/logo?light=false"
|
||||||
|
forceColorScheme="dark"
|
||||||
/>
|
/>
|
||||||
<ApplicationImage
|
<ApplicationImage
|
||||||
id="background-image"
|
id="background-image"
|
||||||
class="basis-full lg:basis-auto"
|
|
||||||
imageClass="h-[350px] max-w-[500px]"
|
imageClass="h-[350px] max-w-[500px]"
|
||||||
label="Background Image"
|
label="Background Image"
|
||||||
bind:image={backgroundImage}
|
bind:image={backgroundImage}
|
||||||
@@ -39,5 +53,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<Button class="mt-5" onclick={() => callback(logo, backgroundImage, favicon)}>Save</Button>
|
<Button class="mt-5" onclick={() => callback(logoLight, logoDark, backgroundImage, favicon)}
|
||||||
|
>Save</Button
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { beforeNavigate } from '$app/navigation';
|
import { beforeNavigate } from '$app/navigation';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
import { openConfirmDialog } from '$lib/components/confirm-dialog';
|
import { openConfirmDialog } from '$lib/components/confirm-dialog';
|
||||||
|
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
import Label from '$lib/components/ui/label/label.svelte';
|
import Label from '$lib/components/ui/label/label.svelte';
|
||||||
@@ -26,7 +27,6 @@
|
|||||||
'Token URL': `https://${$page.url.hostname}/api/oidc/token`,
|
'Token URL': `https://${$page.url.hostname}/api/oidc/token`,
|
||||||
'Userinfo URL': `https://${$page.url.hostname}/api/oidc/userinfo`,
|
'Userinfo URL': `https://${$page.url.hostname}/api/oidc/userinfo`,
|
||||||
'Certificate URL': `https://${$page.url.hostname}/.well-known/jwks.json`,
|
'Certificate URL': `https://${$page.url.hostname}/.well-known/jwks.json`,
|
||||||
PKCE: 'Disabled'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
async function updateClient(updatedClient: OidcClientCreateWithLogo) {
|
async function updateClient(updatedClient: OidcClientCreateWithLogo) {
|
||||||
@@ -89,7 +89,9 @@
|
|||||||
<div class="flex flex-col">
|
<div class="flex flex-col">
|
||||||
<div class="mb-2 flex">
|
<div class="mb-2 flex">
|
||||||
<Label class="mb-0 w-44">Client ID</Label>
|
<Label class="mb-0 w-44">Client ID</Label>
|
||||||
<span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span>
|
<CopyToClipboard value={client.id}>
|
||||||
|
<span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span>
|
||||||
|
</CopyToClipboard>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-2 mt-1 flex items-center">
|
<div class="mb-2 mt-1 flex items-center">
|
||||||
<Label class="w-44">Client secret</Label>
|
<Label class="w-44">Client secret</Label>
|
||||||
@@ -111,7 +113,9 @@
|
|||||||
{#each Object.entries(setupDetails) as [key, value]}
|
{#each Object.entries(setupDetails) as [key, value]}
|
||||||
<div class="mb-5 flex">
|
<div class="mb-5 flex">
|
||||||
<Label class="mb-0 w-44">{key}</Label>
|
<Label class="mb-0 w-44">{key}</Label>
|
||||||
<span class="text-muted-foreground text-sm">{value}</span>
|
<CopyToClipboard {value}>
|
||||||
|
<span class="text-muted-foreground text-sm">{value}</span>
|
||||||
|
</CopyToClipboard>
|
||||||
</div>
|
</div>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import OIDCService from '$lib/services/oidc-service';
|
import OIDCService from '$lib/services/oidc-service';
|
||||||
import type { OidcClient } from '$lib/types/oidc.type';
|
import type { OidcClient } from '$lib/types/oidc.type';
|
||||||
import type { Paginated, PaginationRequest } from '$lib/types/pagination.type';
|
import type { Paginated, PaginationRequest } from '$lib/types/pagination.type';
|
||||||
|
import { debounced } from '$lib/utils/debounce-util';
|
||||||
import { axiosErrorToast } from '$lib/utils/error-util';
|
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||||
import { LucidePencil, LucideTrash } from 'lucide-svelte';
|
import { LucidePencil, LucideTrash } from 'lucide-svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
@@ -28,6 +29,10 @@
|
|||||||
});
|
});
|
||||||
let search = $state('');
|
let search = $state('');
|
||||||
|
|
||||||
|
const debouncedSearch = debounced(async (searchValue: string) => {
|
||||||
|
clients = await oidcService.listClients(searchValue, pagination);
|
||||||
|
}, 400);
|
||||||
|
|
||||||
async function deleteClient(client: OidcClient) {
|
async function deleteClient(client: OidcClient) {
|
||||||
openConfirmDialog({
|
openConfirmDialog({
|
||||||
title: `Delete ${client.name}`,
|
title: `Delete ${client.name}`,
|
||||||
@@ -53,8 +58,7 @@
|
|||||||
type="search"
|
type="search"
|
||||||
placeholder="Search clients"
|
placeholder="Search clients"
|
||||||
bind:value={search}
|
bind:value={search}
|
||||||
on:input={async (e) =>
|
on:input={(e) => debouncedSearch((e.target as HTMLInputElement).value)}
|
||||||
(clients = await oidcService.listClients((e.target as HTMLInputElement).value, pagination))}
|
|
||||||
/>
|
/>
|
||||||
<Table.Root>
|
<Table.Root>
|
||||||
<Table.Header class="sr-only">
|
<Table.Header class="sr-only">
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import UserGroupService from '$lib/services/user-group-service';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ cookies }) => {
|
||||||
|
const userGroupService = new UserGroupService(cookies.get('access_token'));
|
||||||
|
const userGroups = await userGroupService.list();
|
||||||
|
return userGroups;
|
||||||
|
};
|
||||||
73
frontend/src/routes/settings/admin/user-groups/+page.svelte
Normal file
73
frontend/src/routes/settings/admin/user-groups/+page.svelte
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import UserGroupService from '$lib/services/user-group-service';
|
||||||
|
import type { Paginated } from '$lib/types/pagination.type';
|
||||||
|
import type { UserGroupCreate, UserGroupWithUserCount } from '$lib/types/user-group.type';
|
||||||
|
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||||
|
import { LucideMinus } from 'lucide-svelte';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import { slide } from 'svelte/transition';
|
||||||
|
import UserGroupForm from './user-group-form.svelte';
|
||||||
|
import UserGroupList from './user-group-list.svelte';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
let userGroups: Paginated<UserGroupWithUserCount> = $state(data);
|
||||||
|
let expandAddUserGroup = $state(false);
|
||||||
|
|
||||||
|
const userGroupService = new UserGroupService();
|
||||||
|
|
||||||
|
async function createUserGroup(userGroup: UserGroupCreate) {
|
||||||
|
let success = true;
|
||||||
|
await userGroupService
|
||||||
|
.create(userGroup)
|
||||||
|
.then((createdUserGroup) => {
|
||||||
|
toast.success('User group created successfully');
|
||||||
|
goto(`/settings/admin/user-groups/${createdUserGroup.id}`);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
axiosErrorToast(e);
|
||||||
|
success = false;
|
||||||
|
});
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>User Groups</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Card.Title>Create User Group</Card.Title>
|
||||||
|
<Card.Description>Create a new group that can be assigned to users.</Card.Description>
|
||||||
|
</div>
|
||||||
|
{#if !expandAddUserGroup}
|
||||||
|
<Button on:click={() => (expandAddUserGroup = true)}>Add Group</Button>
|
||||||
|
{:else}
|
||||||
|
<Button class="h-8 p-3" variant="ghost" on:click={() => (expandAddUserGroup = false)}>
|
||||||
|
<LucideMinus class="h-5 w-5" />
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</Card.Header>
|
||||||
|
{#if expandAddUserGroup}
|
||||||
|
<div transition:slide>
|
||||||
|
<Card.Content>
|
||||||
|
<UserGroupForm callback={createUserGroup} />
|
||||||
|
</Card.Content>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title>Manage User Groups</Card.Title>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
<UserGroupList {userGroups} />
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import UserGroupService from '$lib/services/user-group-service';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params, cookies }) => {
|
||||||
|
const userGroupService = new UserGroupService(cookies.get('access_token'));
|
||||||
|
const userGroup = await userGroupService.get(params.id);
|
||||||
|
|
||||||
|
return { userGroup };
|
||||||
|
};
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import UserGroupService from '$lib/services/user-group-service';
|
||||||
|
import UserService from '$lib/services/user-service';
|
||||||
|
import type { UserGroupCreate } from '$lib/types/user-group.type';
|
||||||
|
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||||
|
import { LucideChevronLeft } from 'lucide-svelte';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
import UserGroupForm from '../user-group-form.svelte';
|
||||||
|
import UserSelection from '../user-selection.svelte';
|
||||||
|
|
||||||
|
let { data } = $props();
|
||||||
|
let userGroup = $state({
|
||||||
|
...data.userGroup,
|
||||||
|
userIds: data.userGroup.users.map((u) => u.id)
|
||||||
|
});
|
||||||
|
|
||||||
|
const userGroupService = new UserGroupService();
|
||||||
|
const userService = new UserService();
|
||||||
|
|
||||||
|
async function updateUserGroup(updatedUserGroup: UserGroupCreate) {
|
||||||
|
let success = true;
|
||||||
|
await userGroupService
|
||||||
|
.update(userGroup.id, updatedUserGroup)
|
||||||
|
.then(() => toast.success('User group updated successfully'))
|
||||||
|
.catch((e) => {
|
||||||
|
axiosErrorToast(e);
|
||||||
|
success = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
return success;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateUserGroupUsers(userIds: string[]) {
|
||||||
|
await userGroupService
|
||||||
|
.updateUsers(userGroup.id, userIds)
|
||||||
|
.then(() => toast.success('Users updated successfully'))
|
||||||
|
.catch((e) => {
|
||||||
|
axiosErrorToast(e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>User Group Details {userGroup.name}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<a class="text-muted-foreground flex text-sm" href="/settings/admin/user-groups"
|
||||||
|
><LucideChevronLeft class="h-5 w-5" /> Back</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title>Meta data</Card.Title>
|
||||||
|
</Card.Header>
|
||||||
|
|
||||||
|
<Card.Content>
|
||||||
|
<UserGroupForm existingUserGroup={userGroup} callback={updateUserGroup} />
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title>Users</Card.Title>
|
||||||
|
<Card.Description>Assign users to this group.</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
|
||||||
|
<Card.Content>
|
||||||
|
{#await userService.list() then users}
|
||||||
|
<UserSelection {users} bind:selectedUserIds={userGroup.userIds} />
|
||||||
|
{/await}
|
||||||
|
<div class="mt-5 flex justify-end">
|
||||||
|
<Button on:click={() => updateUserGroupUsers(userGroup.userIds)}>Save</Button>
|
||||||
|
</div>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import FormInput from '$lib/components/form-input.svelte';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import type { UserGroupCreate } from '$lib/types/user-group.type';
|
||||||
|
import { createForm } from '$lib/utils/form-util';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
let {
|
||||||
|
callback,
|
||||||
|
existingUserGroup
|
||||||
|
}: {
|
||||||
|
existingUserGroup?: UserGroupCreate;
|
||||||
|
callback: (userGroup: UserGroupCreate) => Promise<boolean>;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let isLoading = $state(false);
|
||||||
|
let hasManualNameEdit = $state(!!existingUserGroup?.friendlyName);
|
||||||
|
|
||||||
|
const userGroup = {
|
||||||
|
name: existingUserGroup?.name || '',
|
||||||
|
friendlyName: existingUserGroup?.friendlyName || ''
|
||||||
|
};
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
friendlyName: z.string().min(2).max(30),
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.min(2)
|
||||||
|
.max(30)
|
||||||
|
.regex(/^[a-z0-9_]+$/, 'Name can only contain lowercase letters, numbers, and underscores')
|
||||||
|
});
|
||||||
|
type FormSchema = typeof formSchema;
|
||||||
|
|
||||||
|
const { inputs, ...form } = createForm<FormSchema>(formSchema, userGroup);
|
||||||
|
|
||||||
|
function onFriendlyNameInput(e: any) {
|
||||||
|
if (!hasManualNameEdit) {
|
||||||
|
$inputs.name.value = e.target!.value.toLowerCase().replace(/[^a-z0-9_]/g, '_');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNameInput(_: Event) {
|
||||||
|
hasManualNameEdit = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
const data = form.validate();
|
||||||
|
if (!data) return;
|
||||||
|
isLoading = true;
|
||||||
|
const success = await callback(data);
|
||||||
|
// Reset form if user group was successfully created
|
||||||
|
if (success && !existingUserGroup) {
|
||||||
|
form.reset();
|
||||||
|
hasManualNameEdit = false;
|
||||||
|
}
|
||||||
|
isLoading = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form onsubmit={onSubmit}>
|
||||||
|
<div class="flex flex-col gap-3 sm:flex-row">
|
||||||
|
<div class="w-full">
|
||||||
|
<FormInput
|
||||||
|
label="Friendly Name"
|
||||||
|
description="Name that will be displayed in the UI"
|
||||||
|
bind:input={$inputs.friendlyName}
|
||||||
|
onInput={onFriendlyNameInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="w-full">
|
||||||
|
<FormInput
|
||||||
|
label="Name"
|
||||||
|
description={`Name that will be in the "groups" claim`}
|
||||||
|
bind:input={$inputs.name}
|
||||||
|
onInput={onNameInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-5 flex justify-end">
|
||||||
|
<Button {isLoading} type="submit">Save</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import AdvancedTable from '$lib/components/advanced-table.svelte';
|
||||||
|
import { openConfirmDialog } from '$lib/components/confirm-dialog/';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||||
|
import * as Table from '$lib/components/ui/table';
|
||||||
|
import UserGroupService from '$lib/services/user-group-service';
|
||||||
|
import type { Paginated } from '$lib/types/pagination.type';
|
||||||
|
import type { UserGroup, UserGroupWithUserCount } from '$lib/types/user-group.type';
|
||||||
|
import { axiosErrorToast } from '$lib/utils/error-util';
|
||||||
|
import { LucidePencil, LucideTrash } from 'lucide-svelte';
|
||||||
|
import Ellipsis from 'lucide-svelte/icons/ellipsis';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
|
let { userGroups: initialUserGroups }: { userGroups: Paginated<UserGroupWithUserCount> } =
|
||||||
|
$props();
|
||||||
|
|
||||||
|
let userGroups = $state<Paginated<UserGroupWithUserCount>>(initialUserGroups);
|
||||||
|
|
||||||
|
const userGroupService = new UserGroupService();
|
||||||
|
|
||||||
|
async function deleteUserGroup(userGroup: UserGroup) {
|
||||||
|
openConfirmDialog({
|
||||||
|
title: `Delete ${userGroup.name}`,
|
||||||
|
message: 'Are you sure you want to delete this user group?',
|
||||||
|
confirm: {
|
||||||
|
label: 'Delete',
|
||||||
|
destructive: true,
|
||||||
|
action: async () => {
|
||||||
|
try {
|
||||||
|
await userGroupService.remove(userGroup.id);
|
||||||
|
userGroups = await userGroupService.list();
|
||||||
|
} catch (e) {
|
||||||
|
axiosErrorToast(e);
|
||||||
|
}
|
||||||
|
toast.success('User group deleted successfully');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchItems(search: string, page: number, limit: number) {
|
||||||
|
return userGroupService.list(search, { page, limit });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AdvancedTable items={userGroups} {fetchItems} columns={['Friendly Name', 'Name', 'User Count', {label: "Actions", hidden: true}]}>
|
||||||
|
{#snippet rows({ item })}
|
||||||
|
<Table.Cell>{item.friendlyName}</Table.Cell>
|
||||||
|
<Table.Cell>{item.name}</Table.Cell>
|
||||||
|
<Table.Cell>{item.userCount}</Table.Cell>
|
||||||
|
<Table.Cell class="flex justify-end">
|
||||||
|
<DropdownMenu.Root>
|
||||||
|
<DropdownMenu.Trigger asChild let:builder>
|
||||||
|
<Button aria-haspopup="true" size="icon" variant="ghost" builders={[builder]}>
|
||||||
|
<Ellipsis class="h-4 w-4" />
|
||||||
|
<span class="sr-only">Toggle menu</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenu.Trigger>
|
||||||
|
<DropdownMenu.Content align="end">
|
||||||
|
<DropdownMenu.Item href="/settings/admin/user-groups/{item.id}"
|
||||||
|
><LucidePencil class="mr-2 h-4 w-4" /> Edit</DropdownMenu.Item
|
||||||
|
>
|
||||||
|
<DropdownMenu.Item
|
||||||
|
class="text-red-500 focus:!text-red-700"
|
||||||
|
on:click={() => deleteUserGroup(item)}
|
||||||
|
><LucideTrash class="mr-2 h-4 w-4" />Delete</DropdownMenu.Item
|
||||||
|
>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Root>
|
||||||
|
</Table.Cell>
|
||||||
|
{/snippet}
|
||||||
|
</AdvancedTable>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import AdvancedTable from '$lib/components/advanced-table.svelte';
|
||||||
|
import * as Table from '$lib/components/ui/table';
|
||||||
|
import UserService from '$lib/services/user-service';
|
||||||
|
import type { Paginated } from '$lib/types/pagination.type';
|
||||||
|
import type { User } from '$lib/types/user.type';
|
||||||
|
|
||||||
|
let {
|
||||||
|
users: initialUsers,
|
||||||
|
selectedUserIds = $bindable()
|
||||||
|
}: { users: Paginated<User>; selectedUserIds: string[] } = $props();
|
||||||
|
|
||||||
|
const userService = new UserService();
|
||||||
|
|
||||||
|
let users = $state(initialUsers);
|
||||||
|
|
||||||
|
function fetchItems(search: string, page: number, limit: number) {
|
||||||
|
return userService.list(search, { page, limit });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<AdvancedTable
|
||||||
|
items={users}
|
||||||
|
{fetchItems}
|
||||||
|
columns={['Name', 'Email']}
|
||||||
|
bind:selectedIds={selectedUserIds}
|
||||||
|
>
|
||||||
|
{#snippet rows({ item })}
|
||||||
|
<Table.Cell>{item.firstName} {item.lastName}</Table.Cell>
|
||||||
|
<Table.Cell>{item.email}</Table.Cell>
|
||||||
|
{/snippet}
|
||||||
|
</AdvancedTable>
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
import { LucideMinus } from 'lucide-svelte';
|
import { LucideMinus } from 'lucide-svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
import { toast } from 'svelte-sonner';
|
||||||
import { slide } from 'svelte/transition';
|
import { slide } from 'svelte/transition';
|
||||||
import CreateUser from './user-form.svelte';
|
import UserForm from './user-form.svelte';
|
||||||
import UserList from './user-list.svelte';
|
import UserList from './user-list.svelte';
|
||||||
|
|
||||||
let { data } = $props();
|
let { data } = $props();
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
{#if expandAddUser}
|
{#if expandAddUser}
|
||||||
<div transition:slide>
|
<div transition:slide>
|
||||||
<Card.Content>
|
<Card.Content>
|
||||||
<CreateUser callback={createUser} />
|
<UserForm callback={createUser} />
|
||||||
</Card.Content>
|
</Card.Content>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -56,10 +56,10 @@
|
|||||||
<form onsubmit={onSubmit}>
|
<form onsubmit={onSubmit}>
|
||||||
<div class="flex flex-col gap-3 sm:flex-row">
|
<div class="flex flex-col gap-3 sm:flex-row">
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<FormInput label="Firstname" bind:input={$inputs.firstName} />
|
<FormInput label="First name" bind:input={$inputs.firstName} />
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<FormInput label="Lastname" bind:input={$inputs.lastName} />
|
<FormInput label="Last name" bind:input={$inputs.lastName} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 flex flex-col gap-3 sm:flex-row">
|
<div class="mt-3 flex flex-col gap-3 sm:flex-row">
|
||||||
|
|||||||
@@ -33,7 +33,9 @@
|
|||||||
});
|
});
|
||||||
let search = $state('');
|
let search = $state('');
|
||||||
|
|
||||||
const debouncedFetchUsers = debounced(userService.list, 500);
|
const debouncedSearch = debounced(async (searchValue: string) => {
|
||||||
|
users = await userService.list(searchValue, pagination);
|
||||||
|
}, 400);
|
||||||
|
|
||||||
async function deleteUser(user: User) {
|
async function deleteUser(user: User) {
|
||||||
openConfirmDialog({
|
openConfirmDialog({
|
||||||
@@ -69,12 +71,11 @@
|
|||||||
type="search"
|
type="search"
|
||||||
placeholder="Search users"
|
placeholder="Search users"
|
||||||
bind:value={search}
|
bind:value={search}
|
||||||
on:input={async (e) =>
|
on:input={(e) => debouncedSearch((e.target as HTMLInputElement).value)}
|
||||||
(users = await userService.list((e.target as HTMLInputElement).value, pagination))}
|
|
||||||
/>
|
/>
|
||||||
<Table.Root>
|
<Table.Root>
|
||||||
<Table.Header>
|
<Table.Header>
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Head class="hidden md:table-cell">First name</Table.Head>
|
<Table.Head class="hidden md:table-cell">First name</Table.Head>
|
||||||
<Table.Head class="hidden md:table-cell">Last name</Table.Head>
|
<Table.Head class="hidden md:table-cell">Last name</Table.Head>
|
||||||
<Table.Head>Email</Table.Head>
|
<Table.Head>Email</Table.Head>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Head>Time</Table.Head>
|
<Table.Head>Time</Table.Head>
|
||||||
<Table.Head>Event</Table.Head>
|
<Table.Head>Event</Table.Head>
|
||||||
|
<Table.Head>Approximate Location</Table.Head>
|
||||||
<Table.Head>IP Address</Table.Head>
|
<Table.Head>IP Address</Table.Head>
|
||||||
<Table.Head>Device</Table.Head>
|
<Table.Head>Device</Table.Head>
|
||||||
<Table.Head>Client</Table.Head>
|
<Table.Head>Client</Table.Head>
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
<Table.Cell>
|
<Table.Cell>
|
||||||
<Badge variant="outline">{toFriendlyEventString(auditLog.event)}</Badge>
|
<Badge variant="outline">{toFriendlyEventString(auditLog.event)}</Badge>
|
||||||
</Table.Cell>
|
</Table.Cell>
|
||||||
|
<Table.Cell>{auditLog.city && auditLog.country ? `${auditLog.city}, ${auditLog.country}` : 'Unknown'}</Table.Cell>
|
||||||
<Table.Cell>{auditLog.ipAddress}</Table.Cell>
|
<Table.Cell>{auditLog.ipAddress}</Table.Cell>
|
||||||
<Table.Cell>{auditLog.device}</Table.Cell>
|
<Table.Cell>{auditLog.device}</Table.Cell>
|
||||||
<Table.Cell>{auditLog.data.clientName}</Table.Cell>
|
<Table.Cell>{auditLog.data.clientName}</Table.Cell>
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ test.beforeEach(cleanupBackend);
|
|||||||
test('Update account details', async ({ page }) => {
|
test('Update account details', async ({ page }) => {
|
||||||
await page.goto('/settings/account');
|
await page.goto('/settings/account');
|
||||||
|
|
||||||
await page.getByLabel('Firstname').fill('Timothy');
|
await page.getByLabel('First name').fill('Timothy');
|
||||||
await page.getByLabel('Lastname').fill('Apple');
|
await page.getByLabel('Last name').fill('Apple');
|
||||||
await page.getByLabel('Email').fill('timothy.apple@test.com');
|
await page.getByLabel('Email').fill('timothy.apple@test.com');
|
||||||
await page.getByLabel('Username').fill('timothy');
|
await page.getByLabel('Username').fill('timothy');
|
||||||
await page.getByRole('button', { name: 'Save' }).click();
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
|||||||
@@ -52,7 +52,8 @@ test('Update application images', async ({ page }) => {
|
|||||||
await page.goto('/settings/admin/application-configuration');
|
await page.goto('/settings/admin/application-configuration');
|
||||||
|
|
||||||
await page.getByLabel('Favicon').setInputFiles('tests/assets/w3-schools-favicon.ico');
|
await page.getByLabel('Favicon').setInputFiles('tests/assets/w3-schools-favicon.ico');
|
||||||
await page.getByLabel('Logo').setInputFiles('tests/assets/pingvin-share-logo.png');
|
await page.getByLabel('Light Mode Logo').setInputFiles('tests/assets/pingvin-share-logo.png');
|
||||||
|
await page.getByLabel('Dark Mode Logo').setInputFiles('tests/assets/nextcloud-logo.png');
|
||||||
await page.getByLabel('Background Image').setInputFiles('tests/assets/clouds.jpg');
|
await page.getByLabel('Background Image').setInputFiles('tests/assets/clouds.jpg');
|
||||||
await page.getByRole('button', { name: 'Save' }).nth(1).click();
|
await page.getByRole('button', { name: 'Save' }).nth(1).click();
|
||||||
|
|
||||||
@@ -62,9 +63,11 @@ test('Update application images', async ({ page }) => {
|
|||||||
.get('/api/application-configuration/favicon')
|
.get('/api/application-configuration/favicon')
|
||||||
.then((res) => expect.soft(res.status()).toBe(200));
|
.then((res) => expect.soft(res.status()).toBe(200));
|
||||||
await page.request
|
await page.request
|
||||||
.get('/api/application-configuration/logo')
|
.get('/api/application-configuration/logo?light=true')
|
||||||
|
.then((res) => expect.soft(res.status()).toBe(200));
|
||||||
|
await page.request
|
||||||
|
.get('/api/application-configuration/logo?light=false')
|
||||||
.then((res) => expect.soft(res.status()).toBe(200));
|
.then((res) => expect.soft(res.status()).toBe(200));
|
||||||
|
|
||||||
await page.request
|
await page.request
|
||||||
.get('/api/application-configuration/background-image')
|
.get('/api/application-configuration/background-image')
|
||||||
.then((res) => expect.soft(res.status()).toBe(200));
|
.then((res) => expect.soft(res.status()).toBe(200));
|
||||||
|
|||||||
@@ -38,3 +38,20 @@ export const oidcClients = {
|
|||||||
secondCallbackUrl: 'http://pingvin.share/auth/callback2'
|
secondCallbackUrl: 'http://pingvin.share/auth/callback2'
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const userGroups = {
|
||||||
|
developers: {
|
||||||
|
id: '4110f814-56f1-4b28-8998-752b69bc97c0e',
|
||||||
|
friendlyName: 'Developers',
|
||||||
|
name: 'developers'
|
||||||
|
},
|
||||||
|
designers: {
|
||||||
|
id: 'adab18bf-f89d-4087-9ee1-70ff15b48211',
|
||||||
|
friendlyName: 'Designers',
|
||||||
|
name: 'designers'
|
||||||
|
},
|
||||||
|
humanResources: {
|
||||||
|
friendlyName: 'Human Resources',
|
||||||
|
name: 'human_resources'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
74
frontend/tests/user-group.spec.ts
Normal file
74
frontend/tests/user-group.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import test, { expect } from '@playwright/test';
|
||||||
|
import { userGroups, users } from './data';
|
||||||
|
import { cleanupBackend } from './utils/cleanup.util';
|
||||||
|
|
||||||
|
test.beforeEach(cleanupBackend);
|
||||||
|
|
||||||
|
test('Create user group', async ({ page }) => {
|
||||||
|
await page.goto('/settings/admin/user-groups');
|
||||||
|
const group = userGroups.humanResources;
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Add Group' }).click();
|
||||||
|
await page.getByLabel('Friendly Name').fill(group.friendlyName);
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('status')).toHaveText('User group created successfully');
|
||||||
|
expect(page.url()).toMatch(/\/settings\/admin\/user-groups\/[a-f0-9-]+/);
|
||||||
|
|
||||||
|
await expect(page.getByLabel('Friendly Name')).toHaveValue(group.friendlyName);
|
||||||
|
await expect(page.getByLabel('Name', { exact: true })).toHaveValue(group.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Edit user group', async ({ page }) => {
|
||||||
|
await page.goto('/settings/admin/user-groups');
|
||||||
|
const group = userGroups.developers;
|
||||||
|
|
||||||
|
await page.getByRole('row', { name: group.name }).getByRole('button').click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
||||||
|
|
||||||
|
await page.getByLabel('Friendly Name').fill('Developers updated');
|
||||||
|
|
||||||
|
await expect(page.getByLabel('Name', { exact: true })).toHaveValue(group.name);
|
||||||
|
|
||||||
|
await page.getByLabel('Name', { exact: true }).fill('developers_updated');
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Save' }).nth(0).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('status')).toHaveText('User group updated successfully');
|
||||||
|
await expect(page.getByLabel('Friendly Name')).toHaveValue('Developers updated');
|
||||||
|
await expect(page.getByLabel('Name', { exact: true })).toHaveValue('developers_updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Update user group users', async ({ page }) => {
|
||||||
|
const group = userGroups.designers;
|
||||||
|
await page.goto(`/settings/admin/user-groups/${group.id}`);
|
||||||
|
|
||||||
|
await page.getByRole('row', { name: users.tim.email }).getByRole('checkbox').click();
|
||||||
|
await page.getByRole('row', { name: users.craig.email }).getByRole('checkbox').click();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Save' }).nth(1).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('status')).toHaveText('Users updated successfully');
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
page.getByRole('row', { name: users.tim.email }).getByRole('checkbox')
|
||||||
|
).toHaveAttribute('data-state', 'unchecked');
|
||||||
|
await expect(
|
||||||
|
page.getByRole('row', { name: users.craig.email }).getByRole('checkbox')
|
||||||
|
).toHaveAttribute('data-state', 'checked');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Delete user group', async ({ page }) => {
|
||||||
|
const group = userGroups.developers;
|
||||||
|
await page.goto('/settings/admin/user-groups');
|
||||||
|
|
||||||
|
await page.getByRole('row', { name: group.name }).getByRole('button').click();
|
||||||
|
await page.getByRole('menuitem', { name: 'Delete' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Delete' }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('status')).toHaveText('User group deleted successfully');
|
||||||
|
await expect(page.getByRole('row', { name: group.name })).not.toBeVisible();
|
||||||
|
});
|
||||||
@@ -10,8 +10,8 @@ test('Create user', async ({ page }) => {
|
|||||||
await page.goto('/settings/admin/users');
|
await page.goto('/settings/admin/users');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Add User' }).click();
|
await page.getByRole('button', { name: 'Add User' }).click();
|
||||||
await page.getByLabel('Firstname').fill(user.firstname);
|
await page.getByLabel('First name').fill(user.firstname);
|
||||||
await page.getByLabel('Lastname').fill(user.lastname);
|
await page.getByLabel('Last name').fill(user.lastname);
|
||||||
await page.getByLabel('Email').fill(user.email);
|
await page.getByLabel('Email').fill(user.email);
|
||||||
await page.getByLabel('Username').fill(user.username);
|
await page.getByLabel('Username').fill(user.username);
|
||||||
await page.getByRole('button', { name: 'Save' }).click();
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
@@ -26,8 +26,8 @@ test('Create user fails with already taken email', async ({ page }) => {
|
|||||||
await page.goto('/settings/admin/users');
|
await page.goto('/settings/admin/users');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Add User' }).click();
|
await page.getByRole('button', { name: 'Add User' }).click();
|
||||||
await page.getByLabel('Firstname').fill(user.firstname);
|
await page.getByLabel('First name').fill(user.firstname);
|
||||||
await page.getByLabel('Lastname').fill(user.lastname);
|
await page.getByLabel('Last name').fill(user.lastname);
|
||||||
await page.getByLabel('Email').fill(users.tim.email);
|
await page.getByLabel('Email').fill(users.tim.email);
|
||||||
await page.getByLabel('Username').fill(user.username);
|
await page.getByLabel('Username').fill(user.username);
|
||||||
await page.getByRole('button', { name: 'Save' }).click();
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
@@ -41,8 +41,8 @@ test('Create user fails with already taken username', async ({ page }) => {
|
|||||||
await page.goto('/settings/admin/users');
|
await page.goto('/settings/admin/users');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Add User' }).click();
|
await page.getByRole('button', { name: 'Add User' }).click();
|
||||||
await page.getByLabel('Firstname').fill(user.firstname);
|
await page.getByLabel('First name').fill(user.firstname);
|
||||||
await page.getByLabel('Lastname').fill(user.lastname);
|
await page.getByLabel('Last name').fill(user.lastname);
|
||||||
await page.getByLabel('Email').fill(user.email);
|
await page.getByLabel('Email').fill(user.email);
|
||||||
await page.getByLabel('Username').fill(users.tim.username);
|
await page.getByLabel('Username').fill(users.tim.username);
|
||||||
await page.getByRole('button', { name: 'Save' }).click();
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
@@ -91,8 +91,8 @@ test('Update user', async ({ page }) => {
|
|||||||
.click();
|
.click();
|
||||||
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
await page.getByRole('menuitem', { name: 'Edit' }).click();
|
||||||
|
|
||||||
await page.getByLabel('Firstname').fill('Crack');
|
await page.getByLabel('First name').fill('Crack');
|
||||||
await page.getByLabel('Lastname').fill('Apple');
|
await page.getByLabel('Last name').fill('Apple');
|
||||||
await page.getByLabel('Email').fill('crack.apple@test.com');
|
await page.getByLabel('Email').fill('crack.apple@test.com');
|
||||||
await page.getByLabel('Username').fill('crack');
|
await page.getByLabel('Username').fill('crack');
|
||||||
await page.getByRole('button', { name: 'Save' }).click();
|
await page.getByRole('button', { name: 'Save' }).click();
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user