Registration form for new users

This commit is contained in:
Simon Martens
2025-05-23 16:26:03 +02:00
parent f641a32cb5
commit c44467f229
22 changed files with 805 additions and 9 deletions

View File

@@ -7,6 +7,7 @@ import (
type FixedAccessToken struct {
Token string `json:"token"`
CSRF string `json:"csrf"`
User string `json:"user"`
Created string `json:"created"`
Updated string `json:"updated"`
@@ -15,6 +16,10 @@ type FixedAccessToken struct {
Status string `json:"status"`
}
func (s *FixedAccessToken) IsExpired() bool {
return s.Expires.IsZero() || s.Expires.Before(types.NowDateTime())
}
var _ core.RecordProxy = (*AccessToken)(nil)
type AccessToken struct {
@@ -43,6 +48,14 @@ func (u *AccessToken) User() string {
return u.GetString(ACCESS_TOKENS_USER_FIELD)
}
func (u *AccessToken) CSRF() string {
return u.GetString(ACCESS_TOKENS_CSRF_FIELD)
}
func (u *AccessToken) SetCSRF(csrf string) {
u.Set(ACCESS_TOKENS_CSRF_FIELD, csrf)
}
func (u *AccessToken) SetUser(userId string) {
u.Set(ACCESS_TOKENS_USER_FIELD, userId)
}
@@ -79,6 +92,10 @@ func (u *AccessToken) SetStatus(status string) {
u.Set(ACCESS_TOKENS_STATUS_FIELD, status)
}
func (u *AccessToken) IsExpired() bool {
return u.Expires().IsZero() || u.Expires().Before(types.NowDateTime())
}
func (u *AccessToken) Fixed() *FixedAccessToken {
return &FixedAccessToken{
Token: u.Token(),

View File

@@ -0,0 +1,47 @@
package dbmodels
import (
"fmt"
"time"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/types"
)
func CreateAccessToken(
app core.App,
userID string,
url string,
duration time.Duration,
) (*AccessToken, error) {
collection, err := app.FindCollectionByNameOrId(ACCESS_TOKENS_TABLE)
if err != nil {
return nil, fmt.Errorf("failed to find '%s' collection: %w", ACCESS_TOKENS_TABLE, err)
}
accesTokenClear, err := generateSecureRandomToken(secureTokenByteLength)
if err != nil {
return nil, fmt.Errorf("failed to generate session token: %w", err)
}
csrfTokenClear, err := generateSecureRandomToken(secureTokenByteLength)
if err != nil {
return nil, fmt.Errorf("failed to generate CSRF token: %w", err)
}
record := core.NewRecord(collection)
token := NewAccessToken(record)
token.SetToken(accesTokenClear)
token.SetCSRF(csrfTokenClear)
token.SetUser(userID)
token.SetURL(url)
token.SetStatus(TOKEN_STATUS_VALUES[0]) // Active
token.SetExpires(types.NowDateTime().Add(duration))
if err := app.Save(record); err != nil {
return nil, fmt.Errorf("failed to save access token record: %w", err)
}
return token, nil
}

View File

@@ -0,0 +1,35 @@
package dbmodels
import (
"fmt"
"github.com/pocketbase/pocketbase/core"
)
func CreateUser(
app core.App,
email string,
password string,
name string,
role string,
) (*User, error) {
collection, err := app.FindCollectionByNameOrId(USERS_TABLE)
if err != nil {
return nil, fmt.Errorf("failed to find '%s' collection: %w", USERS_TABLE, err)
}
record := core.NewRecord(collection)
user := NewUser(record)
user.SetEmail(email)
user.SetPassword(password)
user.SetName(name)
user.SetVerified(true)
user.SetDeactivated(false)
user.SetRole(role)
if err := app.Save(record); err != nil {
return nil, fmt.Errorf("failed to save user record: %w", err)
}
return user, nil
}

26
middleware/accesstoken.go Normal file
View File

@@ -0,0 +1,26 @@
package middleware
import (
"github.com/Theodor-Springmann-Stiftung/musenalm/templating"
"github.com/pocketbase/pocketbase/core"
)
func HasToken() func(*core.RequestEvent) error {
return func(e *core.RequestEvent) error {
req := templating.NewRequest(e)
token := req.AccessToken()
if token == nil {
return e.Error(401, "Unauthorized", nil)
}
if token.IsExpired() {
return e.Error(403, "Forbidden", nil)
}
if token.URL != e.Request.URL.Path {
return e.Error(403, "Forbidden", nil)
}
return e.Next()
}
}

22
middleware/admin.go Normal file
View File

@@ -0,0 +1,22 @@
package middleware
import (
"github.com/Theodor-Springmann-Stiftung/musenalm/templating"
"github.com/pocketbase/pocketbase/core"
)
func IsAdmin() func(*core.RequestEvent) error {
return func(e *core.RequestEvent) error {
req := templating.NewRequest(e)
user := req.User()
if user == nil {
return e.Error(401, "Unauthorized", nil)
}
if user.Role != "Admin" {
return e.Error(403, "Forbidden", nil)
}
return e.Next()
}
}

View File

@@ -63,7 +63,7 @@ func accessTokensFields(usersCollectionId string) core.FieldsList {
},
&core.RelationField{
Name: dbmodels.ACCESS_TOKENS_USER_FIELD,
Required: true,
Required: false,
CollectionId: usersCollectionId,
CascadeDelete: true,
Presentable: true,

View File

@@ -36,4 +36,7 @@ const (
P_LOGIN_NAME = "login"
P_LOGOUT_NAME = "logout"
P_USER_MGMT_ACCESS_NAME = "user_management_access"
P_USER_CREATE_NAME = "user_create"
)

136
pages/user_create.go Normal file
View File

@@ -0,0 +1,136 @@
package pages
import (
"fmt"
"github.com/Theodor-Springmann-Stiftung/musenalm/app"
"github.com/Theodor-Springmann-Stiftung/musenalm/dbmodels"
"github.com/Theodor-Springmann-Stiftung/musenalm/middleware"
"github.com/Theodor-Springmann-Stiftung/musenalm/pagemodels"
"github.com/Theodor-Springmann-Stiftung/musenalm/templating"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
)
const (
URL_USER_CREATE = "/user/new/"
PATH_VALUE_ROLE = "role"
TEMPLATE_USER_CREATE = "/user/new/"
)
func init() {
ucp := &UserCreatePage{
StaticPage: pagemodels.StaticPage{
Name: pagemodels.P_USER_CREATE_NAME,
Layout: "blank",
Template: TEMPLATE_USER_CREATE,
URL: URL_USER_CREATE,
},
}
app.Register(ucp)
}
type UserCreatePage struct {
pagemodels.StaticPage
}
func (p *UserCreatePage) Setup(router *router.Router[*core.RequestEvent], app core.App, engine *templating.Engine) error {
rg := router.Group(URL_USER_CREATE)
rg.BindFunc(middleware.HasToken())
rg.GET("{"+PATH_VALUE_ROLE+"}", p.GET(engine, app))
rg.POST("{"+PATH_VALUE_ROLE+"}", p.POST(engine, app))
return nil
}
func (p *UserCreatePage) GET(engine *templating.Engine, app core.App) HandleFunc {
return func(e *core.RequestEvent) error {
role := e.Request.PathValue("role")
if role != "User" && role != "Editor" && role != "Admin" {
return engine.Response404(e, fmt.Errorf("invalid role: %s", role), nil)
}
// TODO: check access token
data := make(map[string]any)
nonce, token, err := CSRF_CACHE.GenerateTokenBundle()
if err != nil {
return engine.Response500(e, err, data)
}
data["role"] = role
data["csrf_nonce"] = nonce
data["csrf_token"] = token
return engine.Response200(e, p.Template, data, p.Layout)
}
}
func InvalidSignupResponse(engine *templating.Engine, e *core.RequestEvent, error string) error {
data := make(map[string]any)
data["error"] = error
nonce, token, err := CSRF_CACHE.GenerateTokenBundle()
if err != nil {
return engine.Response500(e, err, data)
}
data["csrf_nonce"] = nonce
data["csrf_token"] = token
str, err := engine.RenderToString(e, data, TEMPLATE_USER_CREATE, "blank")
if err != nil {
return engine.Response500(e, err, data)
}
return e.HTML(400, str)
}
func (p *UserCreatePage) POST(engine *templating.Engine, app core.App) HandleFunc {
return func(e *core.RequestEvent) error {
role := e.Request.PathValue("role")
if role != "User" && role != "Editor" && role != "Admin" {
return engine.Response404(e, fmt.Errorf("invalid role: %s", role), nil)
}
// TODO: check access token
data := make(map[string]any)
data["role"] = role
formdata := struct {
Username string `form:"username"`
Password string `form:"password"`
PasswordRepeat string `form:"password_repeat"`
Name string `form:"name"`
CsrfNonce string `form:"csrf_nonce"`
CsrfToken string `form:"csrf_token"`
}{}
if err := e.BindBody(&formdata); err != nil {
return engine.Response500(e, err, data)
}
data["formdata"] = formdata
if _, err := CSRF_CACHE.ValidateTokenBundle(formdata.CsrfNonce, formdata.CsrfToken); err != nil {
return InvalidSignupResponse(engine, e, "CSRF-Token ungültig.")
}
if formdata.Username == "" || formdata.Password == "" || formdata.Name == "" {
return InvalidSignupResponse(engine, e, "Bitte alle Felder ausfüllen.")
}
if formdata.Password != formdata.PasswordRepeat {
return InvalidSignupResponse(engine, e, "Passwörter stimmen nicht überein.")
}
_, err := app.FindAuthRecordByEmail(dbmodels.USERS_TABLE, formdata.Username)
if err == nil {
return InvalidSignupResponse(engine, e, "Benutzer existiert bereits.")
}
user, err := dbmodels.CreateUser(app, formdata.Username, formdata.Password, formdata.Name, role)
if err != nil {
return InvalidSignupResponse(engine, e, fmt.Sprintf("Fehler beim Erstellen des Benutzers: %s", err.Error()))
}
data["user"] = user
return engine.Response200(e, p.Template, data, p.Layout)
}
}

View File

@@ -0,0 +1,119 @@
package pages
import (
"fmt"
"time"
"github.com/Theodor-Springmann-Stiftung/musenalm/app"
"github.com/Theodor-Springmann-Stiftung/musenalm/dbmodels"
"github.com/Theodor-Springmann-Stiftung/musenalm/middleware"
"github.com/Theodor-Springmann-Stiftung/musenalm/pagemodels"
"github.com/Theodor-Springmann-Stiftung/musenalm/templating"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
)
const (
URL_USER_MANAGEMENT_ACCESS = "/user/management/access/"
TEMPLATE_USER_MANAGEMENT_ACCESS = "/user/management/access/"
)
func init() {
ump := &UserManagementAccessPage{
StaticPage: pagemodels.StaticPage{
Name: pagemodels.P_USER_MGMT_ACCESS_NAME,
Layout: "blank",
Template: TEMPLATE_USER_MANAGEMENT_ACCESS,
URL: URL_USER_MANAGEMENT_ACCESS,
},
}
app.Register(ump)
}
type UserManagementAccessPage struct {
pagemodels.StaticPage
}
func (p *UserManagementAccessPage) Setup(router *router.Router[*core.RequestEvent], app core.App, engine *templating.Engine) error {
rg := router.Group(URL_USER_MANAGEMENT_ACCESS)
rg.BindFunc(middleware.IsAdmin())
rg.GET("{"+PATH_VALUE_ROLE+"}", p.GET(engine, app))
rg.POST("{"+PATH_VALUE_ROLE+"}", p.POST(engine, app))
return nil
}
func (p *UserManagementAccessPage) GET(engine *templating.Engine, app core.App) HandleFunc {
return func(e *core.RequestEvent) error {
role := e.Request.PathValue("role")
if role != "User" && role != "Editor" && role != "Admin" {
return engine.Response404(e, fmt.Errorf("invalid role: %s", role), nil)
}
path_access := URL_USER_CREATE + role
record, err := app.FindFirstRecordByData(dbmodels.ACCESS_TOKENS_TABLE, dbmodels.ACCESS_TOKENS_URL_FIELD, path_access)
var access_token *dbmodels.AccessToken
if err != nil {
token, err := dbmodels.CreateAccessToken(app, "", path_access, 7*24*time.Hour)
if err != nil {
return engine.Response500(e, err, nil)
}
access_token = token
} else {
access_token = dbmodels.NewAccessToken(record)
}
// TODO: check if access token exists, if not generate
data := make(map[string]any)
data["role"] = role
data["access_url"] = "https://musenalm.de" + path_access + "?token=" + access_token.Token()
data["relative_url"] = path_access + "?token=" + access_token.Token()
data["validUntil"] = access_token.Expires().Time().Local().Format("02.01.2006 15:04")
nonce, token, err := CSRF_CACHE.GenerateTokenBundle()
if err != nil {
return engine.Response500(e, err, data)
}
data["csrf_nonce"] = nonce
data["csrf_token"] = token
redirect_url := e.Request.URL.Query().Get("redirectTo")
if redirect_url != "" {
data["redirect_url"] = redirect_url
}
return engine.Response200(e, p.Template, data, p.Layout)
}
}
func (p *UserManagementAccessPage) POST(engine *templating.Engine, app core.App) HandleFunc {
return func(e *core.RequestEvent) error {
role := e.Request.PathValue("role")
if role != "User" && role != "Editor" && role != "Admin" {
return engine.Response404(e, fmt.Errorf("invalid role: %s", role), nil)
}
path_access := URL_USER_CREATE + role
record, err := app.FindFirstRecordByData(dbmodels.ACCESS_TOKENS_TABLE, dbmodels.ACCESS_TOKENS_URL_FIELD, path_access)
if err == nil {
go app.Delete(record)
}
token, err := dbmodels.CreateAccessToken(app, "", path_access, 7*24*time.Hour)
if err != nil {
return engine.Response500(e, err, nil)
}
data := make(map[string]any)
data["role"] = role
data["access_url"] = "https://musenalm.de" + path_access + "?token=" + token.Token()
data["relative_url"] = path_access + "?token=" + token.Token()
data["validUntil"] = token.Expires().Time().Format("02.01.2006 15:04")
redirect_url := e.Request.URL.Query().Get("redirectTo")
if redirect_url != "" {
data["redirect_url"] = redirect_url
}
return engine.Response200(e, p.Template, data, p.Layout)
}
}

1
views/assets/js/qrcode.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,31 @@
{{- $date := Today -}}
<footer class="container-normal pb-1.5 text-base text-gray-800">
<footer
class="container-normal pb-1.5 text-base text-gray-800 relative"
x-data="{ openusermenu: false }">
<div class="" x-show="openusermenu">
<div
class="absolute right-0 bottom-10 bg-white border border-gray-300 rounded-md shadow mt-2
[&>a]:no-underline [&>a]:text-gray-700 [&>a]:hover:bg-gray-100 [&>a]:hover:text-gray-900
[&>a]:block [&>a]:px-3 [&>a]:py-2 [&>a]:text-sm [&>a]:rounded-md [&>a]:w-full [&>a]:text-left
[&>a]:whitespace-nowrap [&>a]:transition-all [&>a]:duration-200 [&>a]:border-b
[&>a]:last:border-b-0">
<a href="/user/edit" class="">
<i class="ri-user-3-line"></i>
Profil bearbeiten
</a>
{{ if and .request.user (eq .request.user.Role "Admin") }}
<a href="/user/management/access/User?redirectTo={{ .request.fullpath }}" class="">
<i class="ri-group-3-line"></i>
Benutzer einladen
</a>
{{ end }}
<a href="/logout?redirectTo={{ .request.fullpath }}" class="">
<i class="ri-logout-box-line"></i>
Ausloggen
</a>
</div>
</div>
<div class="mt-12 pt-3 flex flex-row justify-between">
<div>
<i class="ri-creative-commons-line"></i>
@@ -19,15 +45,20 @@
<a href="https://github.com/Theodor-Springmann-Stiftung/musenalm">Code</a>
<span>&middot;</span>
{{ if .request.user }}
<button class="inline-block cursor-pointer" @click="openusermenu = !openusermenu">
<i class="ri-user-3-line"></i>
{{ if .request.user.Name }}
<b>{{ .request.user.Name }}</b>
{{ else }}
<b>{{ .request.user.Email }}</b>
{{ end }}
<i class="ri-arrow-up-s-fill"></i>
</button>
<!--
|
<i class="ri-logout-box-line"></i>
<a href="/logout?redirectTo={{ .request.fullpath }}">Logout</a>
-->
{{ else }}
<i class="ri-login-box-line"></i>
<a href="/login?redirectTo={{ .request.fullpath }}">Login</a>

1
views/public/js/qrcode.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

View File

View File

@@ -0,0 +1,207 @@
{{ $model := . }}
<script src="/assets/js/qrcode.min.js"></script>
<script type="module">
const qrElement = document.getElementById('qr');
const tokenElement = document.getElementById('token');
const accessUrl = "{{ $model.access_url }}";
/**
* @param {number} timeout - Maximum time to wait in milliseconds.
* @param {number} interval - How often to check in milliseconds.
* @returns {Promise<Function>} Resolves with the QRCode constructor when available.
*/
function getQRCodeWhenAvailable(timeout = 5000, interval = 100) {
return new Promise((resolve, reject) => {
let elapsedTime = 0;
const checkInterval = setInterval(() => {
if (typeof window.QRCode === 'function') {
clearInterval(checkInterval);
resolve(window.QRCode); // Resolve with the QRCode object/function
} else {
elapsedTime += interval;
if (elapsedTime >= timeout) {
clearInterval(checkInterval);
console.error('Timed out waiting for QRCode to become available.');
reject(new Error('QRCode not available after ' + timeout + 'ms. Check if qrcode.min.js is loaded correctly and sets window.QRCode.'));
}
}
}, interval);
});
}
// INFO: We have to wait for the QRCode object to be available. It's messy.
async function genQRCode() {
const QRCode = await getQRCodeWhenAvailable();
if (qrElement && accessUrl && qrElement.innerHTML.trim() === '') {
new QRCode(qrElement, {
text: accessUrl,
width: 1280,
height: 1280,
colorDark: "#000000",
colorLight: "#ffffff",
correctLevel: QRCode.CorrectLevel.H
});
setTimeout(() => {
qrElement.classList.remove('hidden');
}, 20);
}
}
genQRCode();
if (tokenElement) {
tokenElement.addEventListener('focus', () => tokenElement.select());
}
</script>
<div class="flex max-w-md mx-auto !pt-44 user-invites">
<div class="flex-col w-full">
{{- if not $model.redirect_url -}}
<a href="/" class="text-gray-700 hover:text-slate-950 mb-9 block">
<i class="ri-arrow-left-s-line"></i> Startseite
</a>
{{- else -}}
<a href="{{ $model.redirect_url }}" class="text-gray-700 hover:text-slate-950 mb-9 block">
<i class="ri-arrow-left-s-line"></i> Zurück
</a>
{{- end -}}
<!--
<div class="mb-6">
<h1 class="text-2xl font-bold text-slate-900 text-center">Benutzer einladen</h1>
</div>
-->
<div
class="col-span-9 grid grid-cols-3 justify-between mb-4 py-1 px-1 border rounded-md gap-x-2
bg-slate-200 user-chooser">
<a
href="/user/management/access/User?redirectTo={{ $model.redirect_url }}"
class="text-center px-4 text-gray-700 hover:text-slate-950 block"
{{ if eq $model.role "User" }}aria-current="page"{{ end }}>
Benutzer
</a>
<a
href="/user/management/access/Editor?redirectTo={{ $model.redirect_url }}"
class="text-gray-700 hover:text-slate-950 block px-4 text-center"
{{ if eq $model.role "Editor" }}aria-current="page"{{ end }}>
Redakteur
</a>
<a
href="/user/management/access/Admin?redirectTo={{ $model.redirect_url }}"
class="text-gray-700 hover:text-slate-950 text-center px-4 block"
{{ if eq $model.role "Admin" }}aria-current="page"{{ end }}>
Admin
</a>
</div>
<!--
<div class="flex justify-center mt-8 items-baseline">
<div>
<img class="h-20 w-20 border" src="/assets/favicon.png" />
</div>
</div>
-->
<div class="col-span-7 col-start-2 mb-4 p-4 border rounded-lg shadow hidden" id="qr"></div>
<div class="col-span-9 mb-6 flex flex-col">
<div class="flex flex-row">
<input
type="text"
name="token"
id="token"
class="w-full text-center border border-slate-300 rounded-md shadow-sm
focus:border-slate-500 focus:ring-slate-500 p-1 px-2 overflow-ellipsis"
value="{{ $model.access_url }}"
deactive
readonly />
<button
type="button"
class="ml-2 inline-flex justify-center py-2 px-3 border border-transparent rounded-md
shadow-sm text-sm font-medium text-white bg-slate-700 hover:bg-slate-800 cursor-pointer
focus:outline-none no-underline
focus:ring-2 focus:ring-offset-2 focus:ring-slate-500"
onclick="navigator.clipboard.writeText('{{ $model.access_url }}')">
<i class="ri-file-copy-line"></i>
</button>
<a
href="{{ $model.relative_url }}"
target="_blank"
class="ml-2 inline-flex justify-center py-2 px-3 border border-transparent rounded-md
shadow-sm text-sm font-medium text-white bg-slate-700 hover:bg-slate-800 cursor-pointer
focus:outline-none no-underline
focus:ring-2 focus:ring-offset-2 focus:ring-slate-500">
<i class="ri-external-link-line"></i>
</a>
</div>
<div class="col-span-9 text-center text-slate-400 mb-1 mt-3">
Gültig bis zum
{{ $model.validUntil }}
</div>
<div class="mt-2 text-sm text-slate-600 flex flex-row gap-x-2">
<div>
<i class="ri-information-line"></i>
</div>
<div>
Unter diesem Link können neue Accounts registriert werden. Geben Sie diesen Link an neue
Nutzer der Datenbank weiter.
</div>
</div>
{{ if eq $model.role "User" }}
<div class="mt-1 text-sm text-blue-600 flex flex-row gap-x-2">
<div>
<i class="ri-information-line"></i>
</div>
<div>Benutzer können private Felder und Daten einsehen, aber nicht bearbeiten.</div>
</div>
{{ else if eq $model.role "Admin" }}
<div class="mt-1 text-sm text-red-600 flex flex-row gap-x-2">
<div>
<i class="ri-information-line"></i>
</div>
<div>
Administratoren können alle Felder und Daten einsehen und bearbeiten. Administratoren
können Nutzer einladen und löschen.
</div>
</div>
{{ else if eq $model.role "Editor" }}
<div class="mt-1 text-sm text-orange-600 flex flex-row gap-x-2">
<div>
<i class="ri-information-line"></i>
</div>
<div>Redakteure können alle Felder und Daten der Datenbank einsehen und bearbeiten.</div>
</div>
{{- end -}}
</div>
<form class="w-full grid grid-cols-9 gap-4" method="POST">
<input
type="hidden"
name="csrf_nonce"
id="csrf_nonce"
required
value="{{ $model.csrf_nonce }}" />
<input
type="hidden"
name="csrf_token"
id="csrf_token"
required
value="{{ $model.csrf_token }}" />
<div class="col-span-9 flex flex-row items-center justify-center">
<button
type="submit"
class="inline-flex justify-center py-2 px-3 border border-transparent rounded-full
shadow-sm text-sm font-medium text-white bg-slate-700 hover:bg-slate-800 cursor-pointer
focus:outline-none no-underline
focus:ring-2 focus:ring-offset-2 focus:ring-slate-500">
<i class="ri-loop-left-line"></i>
</button>
</div>
<!--
<div class="col-span-3">
<a href="/forgot-password" class="text-sm text-slate-600 hover:text-slate-900">
Passwort vergessen?
</a>
</div>
-->
</form>
</div>
</div>

View File

View File

View File

@@ -0,0 +1,143 @@
{{ $model := . }}
<div class="flex max-w-md mx-auto !pt-44">
<div class="flex-col w-full">
<a href="/" class="text-gray-700 hover:text-slate-950">
<i class="ri-arrow-left-s-line"></i> Startseite
</a>
<div class="flex justify-center mt-8 items-baseline">
<div>
<img class="h-20 w-20 border" src="/assets/favicon.png" />
</div>
</div>
<h1 class="text-4xl self-baseline text-center w-full my-6">
Musenalm<br class="mb-3" />
Neuer
{{ if eq $model.role "User" -}}
Nutzer
{{ else if eq $model.role "Admin" -}}
Administrator
{{ else -}}
Redakteur
{{- end -}}
</h1>
{{ if $model.error }}
<div
class="text-red-800 text-sm mt-2 rounded bg-red-200 p-2 font-bold border-red-700
border-2 mb-3">
{{ $model.error }}
</div>
{{ end }}
{{ if not $model.user }}
<form class="w-full grid grid-cols-3 gap-4" method="POST">
<div
class="col-span-3 border-2 border-transparent focus-within:border-slate-600 px-2 py-1 pb-1.5
bg-slate-200 focus-within:bg-slate-50 rounded-md transition-all duration-100">
<label for="username" class="text-sm text-gray-700 font-bold">
Name <i class="ri-text"></i>
</label>
<input
type="text"
name="name"
id="name"
class="mt-1 block w-full rounded-md focus:border-none focus:outline-none"
placeholder=""
required
autocomplete="name"
value=""
autofocus />
</div>
<div
class="col-span-3 border-2 border-transparent focus-within:border-slate-600 px-2 py-1 pb-1.5
bg-slate-200 focus-within:bg-slate-50 rounded-md transition-all duration-100">
<label for="username" class="text-sm text-gray-700 font-bold">
E-Mail <i class="ri-at-line"></i>
</label>
<input
type="email"
name="username"
id="username"
class="mt-1 block w-full rounded-md focus:border-none focus:outline-none"
placeholder=""
required
value=""
autofocus />
</div>
<div
class="col-span-3 border-2 border-transparent focus-within:border-slate-600 px-2 py-1 pb-1.5
bg-slate-200 focus-within:bg-slate-50 rounded-md transition-all duration-100">
<label for="password" class="text-sm text-gray-700 font-bold">
Passwort <i class="ri-key-2-line"></i>
</label>
<input
type="password"
minlength="10"
name="password"
id="password"
class="mt-1 block w-full rounded-md focus:border-none focus:outline-none"
placeholder=""
required />
</div>
<div
class="col-span-3 border-2 border-transparent focus-within:border-slate-600 px-2 py-1 pb-1.5
bg-slate-200 focus-within:bg-slate-50 rounded-md transition-all duration-100">
<label for="password" class="text-sm text-gray-700 font-bold">
Passwort wiederholen <i class="ri-key-2-line"></i>
</label>
<input
type="password"
minlength="10"
name="password_repeat"
id="password_repeat"
class="mt-1 block w-full rounded-md focus:border-none focus:outline-none"
placeholder=""
required />
</div>
<div class="col-span-3">
<input
type="hidden"
name="csrf_nonce"
id="csrf_nonce"
required
value="{{ $model.csrf_nonce }}" />
<input
type="hidden"
name="csrf_token"
id="csrf_token"
required
value="{{ $model.csrf_token }}" />
<button
type="submit"
class="w-full inline-flex justify-center py-2 px-4 border border-transparent rounded-md
shadow-sm text-sm font-medium text-white bg-slate-700 hover:bg-slate-800 cursor-pointer focus:outline-none
focus:ring-2 focus:ring-offset-2 focus:ring-slate-500">
Registrieren
</button>
</div>
<!--
<div class="col-span-3">
<a href="/forgot-password" class="text-sm text-slate-600 hover:text-slate-900">
Passwort vergessen?
</a>
</div>
-->
</form>
{{- else -}}
<div
class="text-green-800 text-sm mt-2 rounded bg-green-200 p-2 font-bold border-green-700
border-2 mb-3">
Benutzer {{ $model.user.Name }} erfolgreich angelegt.
</div>
<div>
<a
href="/login"
class="w-full inline-flex justify-center py-2 px-4 border border-transparent rounded-md
shadow-sm text-sm font-medium text-white bg-slate-700 hover:bg-slate-800 cursor-pointer focus:outline-none
focus:ring-2 focus:ring-offset-2 focus:ring-slate-500">
Login
</a>
</div>
{{- end -}}
</div>
</div>

View File

View File

@@ -512,6 +512,14 @@
@apply !text-slate-900 bg-stone-50;
}
.user-invites .user-chooser a {
@apply py-1 rounded no-underline;
}
.user-invites .user-chooser a[aria-current="page"] {
@apply font-bold !bg-stone-50 relative border-b z-20 shadow;
}
@keyframes spin {
0% {
transform: rotate(0deg);