Add Syndicate Invite Token System (Secure Links)
- Added route + handler: GenerateInviteLinkHandler to create signed tokens - Added handler: JoinSyndicateWithTokenHandler to join using invite token - Integrated secure token generation via helpers.GenerateSecureToken() - Created DB model: syndicate_invite_tokens (assumed pre-existing) - Updated syndicate view template to allow managers to generate links - Flash messaging for invite success/failure - Invite links are scoped to manager role and valid for 48 hours
This commit is contained in:
@@ -56,7 +56,6 @@ func ListSyndicatesHandler(db *sql.DB) http.HandlerFunc {
|
|||||||
managed := storage.GetSyndicatesByOwner(db, userID)
|
managed := storage.GetSyndicatesByOwner(db, userID)
|
||||||
member := storage.GetSyndicatesByMember(db, userID)
|
member := storage.GetSyndicatesByMember(db, userID)
|
||||||
|
|
||||||
// Filter out syndicates where the user is both the owner and a member
|
|
||||||
managedMap := make(map[int]bool)
|
managedMap := make(map[int]bool)
|
||||||
for _, s := range managed {
|
for _, s := range managed {
|
||||||
managedMap[s.ID] = true
|
managedMap[s.ID] = true
|
||||||
@@ -181,7 +180,6 @@ func SyndicateTicketsHandler(db *sql.DB) http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check membership
|
|
||||||
if !storage.IsSyndicateMember(db, syndicateID, userID) {
|
if !storage.IsSyndicateMember(db, syndicateID, userID) {
|
||||||
helpers.RenderError(w, r, 403)
|
helpers.RenderError(w, r, 403)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"synlotto-website/helpers"
|
"synlotto-website/helpers"
|
||||||
"synlotto-website/storage"
|
"synlotto-website/storage"
|
||||||
)
|
)
|
||||||
@@ -89,3 +92,104 @@ func DeclineInviteHandler(db *sql.DB) http.HandlerFunc {
|
|||||||
http.Redirect(w, r, "/syndicate/invites", http.StatusSeeOther)
|
http.Redirect(w, r, "/syndicate/invites", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CreateInviteToken(db *sql.DB, syndicateID, invitedByID int, ttlHours int) (string, error) {
|
||||||
|
token, err := helpers.GenerateSecureToken()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
expires := time.Now().Add(time.Duration(ttlHours) * time.Hour)
|
||||||
|
|
||||||
|
_, err = db.Exec(`
|
||||||
|
INSERT INTO syndicate_invite_tokens (syndicate_id, token, invited_by_user_id, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
`, syndicateID, token, invitedByID, expires)
|
||||||
|
|
||||||
|
return token, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func AcceptInviteToken(db *sql.DB, token string, userID int) error {
|
||||||
|
var syndicateID int
|
||||||
|
var expiresAt, acceptedAt sql.NullTime
|
||||||
|
err := db.QueryRow(`
|
||||||
|
SELECT syndicate_id, expires_at, accepted_at
|
||||||
|
FROM syndicate_invite_tokens
|
||||||
|
WHERE token = ?
|
||||||
|
`, token).Scan(&syndicateID, &expiresAt, &acceptedAt)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid or expired token")
|
||||||
|
}
|
||||||
|
if acceptedAt.Valid || expiresAt.Time.Before(time.Now()) {
|
||||||
|
return fmt.Errorf("token already used or expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.Exec(`
|
||||||
|
INSERT INTO syndicate_members (syndicate_id, user_id, role, status, joined_at)
|
||||||
|
VALUES (?, ?, 'member', 'active', CURRENT_TIMESTAMP)
|
||||||
|
`, syndicateID, userID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.Exec(`
|
||||||
|
UPDATE syndicate_invite_tokens
|
||||||
|
SET accepted_by_user_id = ?, accepted_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE token = ?
|
||||||
|
`, userID, token)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func GenerateInviteLinkHandler(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := helpers.GetCurrentUserID(r)
|
||||||
|
if !ok {
|
||||||
|
helpers.RenderError(w, r, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
syndicateID := helpers.Atoi(r.URL.Query().Get("id"))
|
||||||
|
token, err := CreateInviteToken(db, syndicateID, userID, 48) // token valid for 48 hours
|
||||||
|
if err != nil {
|
||||||
|
helpers.SetFlash(w, r, "Failed to generate invite link.")
|
||||||
|
http.Redirect(w, r, "/syndicate/view?id="+strconv.Itoa(syndicateID), http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
origin := r.Host
|
||||||
|
if r.TLS != nil {
|
||||||
|
origin = "https://" + origin
|
||||||
|
} else {
|
||||||
|
origin = "http://" + origin
|
||||||
|
}
|
||||||
|
inviteLink := fmt.Sprintf("%s/syndicate/join?token=%s", origin, token)
|
||||||
|
|
||||||
|
helpers.SetFlash(w, r, "Invite link created: "+inviteLink)
|
||||||
|
http.Redirect(w, r, "/syndicate/view?id="+strconv.Itoa(syndicateID), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func JoinSyndicateWithTokenHandler(db *sql.DB) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := helpers.GetCurrentUserID(r)
|
||||||
|
if !ok {
|
||||||
|
helpers.RenderError(w, r, http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token := r.URL.Query().Get("token")
|
||||||
|
if token == "" {
|
||||||
|
helpers.SetFlash(w, r, "Invalid or missing invite token.")
|
||||||
|
http.Redirect(w, r, "/syndicate", http.StatusSeeOther)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err := AcceptInviteToken(db, token, userID)
|
||||||
|
if err != nil {
|
||||||
|
helpers.SetFlash(w, r, "Failed to join syndicate: "+err.Error())
|
||||||
|
} else {
|
||||||
|
helpers.SetFlash(w, r, "You have joined the syndicate!")
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/syndicate", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
15
helpers/token.go
Normal file
15
helpers/token.go
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
package helpers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GenerateSecureToken() (string, error) {
|
||||||
|
b := make([]byte, 32)
|
||||||
|
_, err := rand.Read(b)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b), nil
|
||||||
|
}
|
||||||
2
main.go
2
main.go
@@ -92,5 +92,7 @@ func setupSyndicateRoutes(mux *http.ServeMux, db *sql.DB) {
|
|||||||
mux.HandleFunc("/syndicate/invites", middleware.Auth(true)(handlers.ViewInvitesHandler(db)))
|
mux.HandleFunc("/syndicate/invites", middleware.Auth(true)(handlers.ViewInvitesHandler(db)))
|
||||||
mux.HandleFunc("/syndicate/invites/accept", middleware.Auth(true)(handlers.AcceptInviteHandler(db)))
|
mux.HandleFunc("/syndicate/invites/accept", middleware.Auth(true)(handlers.AcceptInviteHandler(db)))
|
||||||
mux.HandleFunc("/syndicate/invites/decline", middleware.Auth(true)(handlers.DeclineInviteHandler(db)))
|
mux.HandleFunc("/syndicate/invites/decline", middleware.Auth(true)(handlers.DeclineInviteHandler(db)))
|
||||||
|
mux.HandleFunc("/syndicate/invite/token", middleware.Auth(true)(handlers.GenerateInviteLinkHandler(db)))
|
||||||
|
mux.HandleFunc("/syndicate/join", middleware.Auth(true)(handlers.JoinSyndicateWithTokenHandler(db)))
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,3 +209,18 @@ CREATE TABLE IF NOT EXISTS syndicate_invites (
|
|||||||
FOREIGN KEY(syndicate_id) REFERENCES syndicates(id),
|
FOREIGN KEY(syndicate_id) REFERENCES syndicates(id),
|
||||||
FOREIGN KEY(invited_user_id) REFERENCES users(id)
|
FOREIGN KEY(invited_user_id) REFERENCES users(id)
|
||||||
);`
|
);`
|
||||||
|
|
||||||
|
const SchemaSyndicateInviteTokens = `
|
||||||
|
CREATE TABLE IF NOT EXISTS syndicate_invite_tokens (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
syndicate_id INTEGER NOT NULL,
|
||||||
|
token TEXT NOT NULL UNIQUE,
|
||||||
|
invited_by_user_id INTEGER NOT NULL,
|
||||||
|
accepted_by_user_id INTEGER,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
accepted_at TIMESTAMP,
|
||||||
|
expires_at TIMESTAMP,
|
||||||
|
FOREIGN KEY (syndicate_id) REFERENCES syndicates(id),
|
||||||
|
FOREIGN KEY (invited_by_user_id) REFERENCES users(id),
|
||||||
|
FOREIGN KEY (accepted_by_user_id) REFERENCES users(id)
|
||||||
|
);`
|
||||||
|
|||||||
@@ -20,9 +20,15 @@
|
|||||||
<strong>Manager Controls</strong><br>
|
<strong>Manager Controls</strong><br>
|
||||||
You can add or remove members, and manage tickets.
|
You can add or remove members, and manage tickets.
|
||||||
</div>
|
</div>
|
||||||
<a href="/syndicate/invite?id={{ .Syndicate.ID }}" class="btn btn-outline-primary">Invite Members</a>
|
|
||||||
{{ end }}
|
|
||||||
|
|
||||||
<a href="/syndicate" class="btn btn-secondary mt-3">← Back to Syndicates</a>
|
<a href="/syndicate/invite?id={{ .Syndicate.ID }}" class="btn btn-outline-primary">Invite Members</a>
|
||||||
</div>
|
|
||||||
{{ end }}
|
<form method="POST" action="/account/syndicates/invite/token?id={{ .Syndicate.ID }}" class="mt-3">
|
||||||
|
{{ .CSRFField }}
|
||||||
|
<button type="submit" class="btn btn-sm btn-outline-primary">Generate Invite Link</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{{ if .Flash }}
|
||||||
|
<div class="alert alert-info mt-2">{{ .Flash }}</div>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
|||||||
Reference in New Issue
Block a user