Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create invitations for closed registrations #233

Merged
merged 4 commits into from
Apr 2, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
invites WIP
  • Loading branch information
thomiceli committed Mar 7, 2024
commit 5aa8f3e9489bd14435c8049c3761c7f9d6c9faf8
2 changes: 1 addition & 1 deletion internal/db/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ func Setup(dbPath string, sharedCache bool) error {
return err
}

if err = db.AutoMigrate(&User{}, &Gist{}, &SSHKey{}, &AdminSetting{}); err != nil {
if err = db.AutoMigrate(&User{}, &Gist{}, &SSHKey{}, &AdminSetting{}, &Invitation{}); err != nil {
return err
}

Expand Down
87 changes: 87 additions & 0 deletions internal/db/invitation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package db

import (
"math/rand"
"time"
)

type Invitation struct {
ID uint `gorm:"primaryKey"`
Code string
ExpiresAt int64
NbUsed uint
NbMax uint
}

func GetAllInvitations() ([]*Invitation, error) {
var invitations []*Invitation
err := db.
Order("(((expires_at >= strftime('%s', 'now')) AND ((nb_max <= 0) OR (nb_used < nb_max)))) desc").
Order("id asc").
Find(&invitations).Error

return invitations, err
}

func GetInvitationByID(id uint) (*Invitation, error) {
invitation := new(Invitation)
err := db.
Where("id = ?", id).
First(&invitation).Error
return invitation, err
}

func GetInvitationByCode(code string) (*Invitation, error) {
invitation := new(Invitation)
err := db.
Where("code = ?", code).
First(&invitation).Error
return invitation, err
}

func InvitationCodeExists(code string) (bool, error) {
var count int64
err := db.Model(&Invitation{}).Where("code = ?", code).Count(&count).Error
return count > 0, err
}

func (i *Invitation) Create() error {
i.Code = generateRandomCode()
return db.Create(&i).Error
}

func (i *Invitation) Update() error {
return db.Save(&i).Error
}

func (i *Invitation) Delete() error {
return db.Delete(&i).Error
}

func (i *Invitation) IsExpired() bool {
return i.ExpiresAt < time.Now().Unix()
}

func (i *Invitation) IsMaxedOut() bool {
return i.NbMax > 0 && i.NbUsed >= i.NbMax
}

func (i *Invitation) IsUsable() bool {
return !i.IsExpired() && !i.IsMaxedOut()
}

func (i *Invitation) Use() error {
i.NbUsed++
return i.Update()
}

func generateRandomCode() string {
const charset = "0123456789ABCDEF"
var seededRand *rand.Rand = rand.New(rand.NewSource(time.Now().UnixNano()))
result := make([]byte, 16)

for i := range result {
result[i] = charset[seededRand.Intn(len(charset))]
}
return string(result)
}
2 changes: 2 additions & 0 deletions internal/i18n/locales/en-US.yml
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@ admin.general: General
admin.users: Users
admin.gists: Gists
admin.configuration: Configuration
admin.invitations: Invitations
admin.invitations.create: Create invitation
admin.versions: Versions
admin.ssh_keys: SSH keys
admin.stats: Stats
Expand Down
66 changes: 66 additions & 0 deletions internal/web/admin.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package web

import (
"fmt"
"github.com/labstack/echo/v4"
"github.com/thomiceli/opengist/internal/actions"
"github.com/thomiceli/opengist/internal/config"
"github.com/thomiceli/opengist/internal/db"
"github.com/thomiceli/opengist/internal/git"
"runtime"
"strconv"
"time"
)

func adminIndex(ctx echo.Context) error {
Expand Down Expand Up @@ -179,3 +181,67 @@ func adminSetConfig(ctx echo.Context) error {
"success": true,
})
}

func adminInvitations(ctx echo.Context) error {
setData(ctx, "title", "Invitations")
setData(ctx, "htmlTitle", "Invitations - Admin panel")
setData(ctx, "adminHeaderPage", "invitations")

var invitations []*db.Invitation
var err error
if invitations, err = db.GetAllInvitations(); err != nil {
return errorRes(500, "Cannot get invites", err)
}

setData(ctx, "invitations", invitations)
return html(ctx, "admin_invitations.html")
}

func adminInvitationsCreate(ctx echo.Context) error {
code := ctx.FormValue("code")
nbMax, err := strconv.ParseUint(ctx.FormValue("nbMax"), 10, 64)
if err != nil {
nbMax = 10
}
expiresAt := ctx.FormValue("expiresAt")
var expiresAtUnix int64
fmt.Println(expiresAt)
if expiresAt == "" {
expiresAtUnix = time.Now().Add(7 * 24 * time.Hour).Unix()
} else {
parsedDate, err := time.Parse("2006-01-02T15:04", expiresAt)
if err != nil {
return errorRes(400, "Invalid date format", err)
}
parsedDateUTC := time.Date(parsedDate.Year(), parsedDate.Month(), parsedDate.Day(), parsedDate.Hour(), parsedDate.Minute(), 0, 0, time.Local)
expiresAtUnix = parsedDateUTC.Unix()
}

invitation := &db.Invitation{
Code: code,
ExpiresAt: expiresAtUnix,
NbMax: uint(nbMax),
}

if err := invitation.Create(); err != nil {
return errorRes(500, "Cannot create invitation", err)
}

addFlash(ctx, "Invitation has been created", "success")
return redirect(ctx, "/admin-panel/invitations")
}

func adminInvitationsDelete(ctx echo.Context) error {
id, _ := strconv.ParseUint(ctx.Param("id"), 10, 64)
invitation, err := db.GetInvitationByID(uint(id))
if err != nil {
return errorRes(500, "Cannot retrieve invitation", err)
}

if err := invitation.Delete(); err != nil {
return errorRes(500, "Cannot delete this invitation", err)
}

addFlash(ctx, "Invitation has been deleted", "success")
return redirect(ctx, "/admin-panel/invitations")
}
31 changes: 29 additions & 2 deletions internal/web/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,38 @@ const (
var title = cases.Title(language.English)

func register(ctx echo.Context) error {
disableSignup := getData(ctx, "DisableSignup")
disableForm := getData(ctx, "DisableLoginForm")

code := ctx.QueryParam("code")
if code != "" {
if invitation, err := db.GetInvitationByCode(code); err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return errorRes(500, "Cannot check for invitation code", err)
} else if invitation != nil && invitation.IsUsable() {
disableSignup = false
}
}

setData(ctx, "title", tr(ctx, "auth.new-account"))
setData(ctx, "htmlTitle", "New account")
setData(ctx, "disableForm", getData(ctx, "DisableLoginForm"))
setData(ctx, "disableForm", disableForm)
setData(ctx, "disableSignup", disableSignup)
setData(ctx, "isLoginPage", false)
return html(ctx, "auth_form.html")
}

func processRegister(ctx echo.Context) error {
if getData(ctx, "DisableSignup") == true {
disableSignup := getData(ctx, "DisableSignup")

code := ctx.QueryParam("code")
invitation, err := db.GetInvitationByCode(code)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return errorRes(500, "Cannot check for invitation code", err)
} else if invitation != nil && invitation.IsUsable() {
disableSignup = false
}

if disableSignup == true {
return errorRes(403, "Signing up is disabled", nil)
}

Expand Down Expand Up @@ -90,6 +113,10 @@ func processRegister(ctx echo.Context) error {
}
}

if err := invitation.Use(); err != nil {
return errorRes(500, "Cannot use invitation", err)
}

sess.Values["user"] = user.ID
saveSession(sess, ctx)

Expand Down
3 changes: 3 additions & 0 deletions internal/web/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,9 @@ func NewServer(isDev bool) *Server {
g2.POST("/users/:user/delete", adminUserDelete)
g2.GET("/gists", adminGists)
g2.POST("/gists/:gist/delete", adminGistDelete)
g2.GET("/invitations", adminInvitations)
g2.POST("/invitations", adminInvitationsCreate)
g2.POST("/invitations/:id/delete", adminInvitationsDelete)
g2.POST("/sync-fs", adminSyncReposFromFS)
g2.POST("/sync-db", adminSyncReposFromDB)
g2.POST("/gc-repos", adminGcRepos)
Expand Down
2 changes: 2 additions & 0 deletions templates/base/admin_header.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ <h1 class="text-2xl font-bold leading-tight">{{ .locale.Tr "admin.admin_panel" }
{{ else }} text-gray-600 dark:text-gray-400 hover:text-gray-400 dark:hover:text-slate-300 px-3 py-2 font-medium text-sm rounded-md {{ end }}" aria-current="page">{{ .locale.Tr "admin.users" }}</a>
<a href="{{ $.c.ExternalUrl }}/admin-panel/gists" class="{{ if eq .adminHeaderPage "gists" }}bg-gray-100 dark:bg-gray-700 text-slate-700 dark:text-slate-300 px-3 py-2 font-medium text-sm rounded-md
{{ else }} text-gray-600 dark:text-gray-400 hover:text-gray-400 dark:hover:text-slate-300 px-3 py-2 font-medium text-sm rounded-md {{ end }}" aria-current="page">{{ .locale.Tr "admin.gists" }}</a>
<a href="{{ $.c.ExternalUrl }}/admin-panel/invitations" class="{{ if eq .adminHeaderPage "invitations" }}bg-gray-100 dark:bg-gray-700 text-slate-700 dark:text-slate-300 px-3 py-2 font-medium text-sm rounded-md
{{ else }} text-gray-600 dark:text-gray-400 hover:text-gray-400 dark:hover:text-slate-300 px-3 py-2 font-medium text-sm rounded-md {{ end }}" aria-current="page">{{ .locale.Tr "admin.invitations" }}</a>
<a href="{{ $.c.ExternalUrl }}/admin-panel/configuration" class="{{ if eq .adminHeaderPage "config" }}bg-gray-100 dark:bg-gray-700 text-slate-700 dark:text-slate-300 px-3 py-2 font-medium text-sm rounded-md
{{ else }} text-gray-600 dark:text-gray-400 hover:text-gray-400 dark:hover:text-slate-300 px-3 py-2 font-medium text-sm rounded-md {{ end }}" aria-current="page">{{ .locale.Tr "admin.configuration" }}</a>
</nav>
Expand Down
56 changes: 56 additions & 0 deletions templates/pages/admin_invitations.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
{{ template "header" .}}
{{ template "admin_header" .}}

<h3 class="text-sm text-gray-600 dark:text-gray-400 italic mb-4">
Invitations can be used to create an account even if signing up is disabled.
</h3>

<form method="POST">
<div class="flex space-x-4">
<div class="flex-1">
<label for="nbMax" class="block text-sm font-medium text-slate-700 dark:text-slate-300">Max uses</label>
<input type="number" id="nbMax" name="nbMax" value="10" min="1" max="100" class="dark:bg-gray-800 appearance-none block w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-md shadow-sm placeholder-gray-600 dark:placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm">
</div>
<div class="flex-1">
<label for="expiresAt" class="block text-sm font-medium text-slate-700 dark:text-slate-300">Expires at</label>
<input type="datetime-local" id="expiresAt" name="expiresAt" class="dark:bg-gray-800 appearance-none block w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-md shadow-sm placeholder-gray-600 dark:placeholder-gray-400 focus:outline-none focus:ring-primary-500 focus:border-primary-500 sm:text-sm">
</div>
</div>
<div class="mt-4">
<button type="submit" class="inline-flex items-center px-4 py-2 border border-transparent border-gray-200 dark:border-gray-700 text-sm font-medium rounded-md shadow-sm text-white dark:text-white bg-primary-500 hover:bg-primary-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-500">{{ .locale.Tr "admin.invitations.create" }}</button>
</div>
{{ .csrfHtml }}
</form>
<hr class="my-4" />
<div class="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8 bg-gray-50 dark:bg-gray-800 rounded-md border border-gray-200 dark:border-gray-700">
<table class="min-w-full divide-y divide-slate-300 dark:divide-gray-500">
<thead>
<tr>
<th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-slate-700 dark:text-slate-300">Code</th>
<th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-slate-700 dark:text-slate-300">Uses</th>
<th scope="col" class="whitespace-nowrap px-2 py-3.5 text-left text-sm font-semibold text-slate-700 dark:text-slate-300">Expires at</th>
<th scope="col" class="relative whitespace-nowrap py-3.5 pl-3 pr-4 sm:pr-0">
<span class="sr-only">{{ .locale.Tr "admin.delete" }}</span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-300 dark:divide-gray-500">
{{ range $invitation := .invitations }}
<tr class="{{ if $invitation.IsUsable }}text-slate-700{{ else }}text-gray-300 italic{{ end }}">
<td class="whitespace-nowrap py-2 px-2 text-sm dark:text-slate-300">{{ $invitation.Code }}</td>
<td class="whitespace-nowrap py-2 px-2 text-sm dark:text-slate-300">{{ $invitation.NbUsed }}/{{ $invitation.NbMax }}</td>
<td class="whitespace-nowrap px-2 py-2 text-sm dark:text-slate-300"><span class="moment-timestamp-date">{{ $invitation.ExpiresAt }}</span></td>
<td class="relative whitespace-nowrap py-2 pl-3 pr-4 text-right text-sm font-medium sm:pr-0">
<form action="{{ $.c.ExternalUrl }}/admin-panel/invitations/{{ $invitation.ID }}/delete" method="POST" onsubmit="return confirm('{{ $.locale.Tr "admin.users.delete_confirm" }}')">
{{ $.csrfHtml }}
<button type="submit" class="text-rose-500 hover:text-rose-600">{{ $.locale.Tr "admin.delete" }}</button>
</form>
</td>
</tr>
{{ end }}
</tbody>
</table>
</div>

{{ template "admin_footer" .}}
{{ template "footer" .}}
2 changes: 1 addition & 1 deletion templates/pages/auth_form.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ <h1 class="text-2xl font-bold leading-tight text-slate-700 dark:text-slate-300">

</header>
<main class="mt-4">
{{ if and .DisableSignup (not .isLoginPage) }}
{{ if .disableSignup }}
<p class="italic">{{ .locale.Tr "auth.signup-disabled" }}</p>
{{ else }}
<div class="sm:col-span-6">
Expand Down