Files
silo-server/internal/api/handlers/admin_invitations.go
95edf19389 feat(invitations): shareable claim links and open-in-app on the claim page (#509)
* feat(invitations): always return the claim link so admins can share it directly

The claim URL was only surfaced when email sending failed. Admins who want
to hand the link over another channel (chat, SMS) had no way to get it —
and the raw token exists only in the send/resend response, since the server
stores just its hash.

The create and resend flows now always include claim_url (additive on
/api/v1), and the admin UI keeps the dialog open after either action with
the link and a labeled Copy button. Truncation and stacked buttons keep the
unbreakable URL from forcing horizontal scroll on phone widths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(web): offer to open invite claims in the Android app

The Android app already registers silo://invite?server=...&token=... with
a full native claim flow, but nothing ever emitted that link — an https
invite always ended in the browser.

On Android user agents the claim page now leads with a prominent 'Open in
the Silo app' button carrying that deep link, with the web form kept below
as the fallback ('or set up in the browser'). The button is a plain anchor:
a user-tapped custom-scheme link is the one reliable path, and we never
fire it automatically since there is no installed-check and a miss surfaces
an OS error. The password field's autofocus is suppressed alongside it so
the keyboard doesn't push the button off screen. iOS is excluded until the
Apple app registers the scheme.

The server origin travels in the server param verbatim, so non-443 ports
and plain-http LAN servers need no extra convention.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 15:01:23 -04:00

212 lines
6.9 KiB
Go

package handlers
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
apimw "github.com/Silo-Server/silo-server/internal/api/middleware"
"github.com/Silo-Server/silo-server/internal/invitations"
"github.com/Silo-Server/silo-server/internal/models"
)
// AdminInvitationHandler handles admin endpoints for emailed invitations.
type AdminInvitationHandler struct {
service *invitations.Service
}
// NewAdminInvitationHandler creates a new AdminInvitationHandler.
func NewAdminInvitationHandler(service *invitations.Service) *AdminInvitationHandler {
return &AdminInvitationHandler{service: service}
}
// --- Request/Response types ---
type createInvitationRequest struct {
Email string `json:"email"`
Role string `json:"role"`
AccessGroupID *int64 `json:"access_group_id"`
LibraryIDs []int `json:"library_ids"`
CreateProfile *bool `json:"create_profile"`
ShowTour *bool `json:"show_tour"`
Note string `json:"note"`
}
type invitationResponse struct {
ID int64 `json:"id"`
Email string `json:"email"`
Role string `json:"role"`
AccessGroupID *int64 `json:"access_group_id,omitempty"`
LibraryIDs []int `json:"library_ids,omitempty"`
CreateProfile bool `json:"create_profile"`
ShowTour bool `json:"show_tour"`
Note string `json:"note,omitempty"`
InvitedBy int64 `json:"invited_by"`
InvitedByName string `json:"invited_by_name,omitempty"`
Status string `json:"status"`
ExpiresAt time.Time `json:"expires_at"`
AcceptedAt *string `json:"accepted_at,omitempty"`
AcceptedUser *int64 `json:"accepted_user_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type sendInvitationResponse struct {
Invitation invitationResponse `json:"invitation"`
EmailSent bool `json:"email_sent"`
// ClaimURL embeds the single-use token, so this response is the only
// chance to read it — the server keeps just the hash. Returned even when
// the email sent, so the admin can also deliver the link directly.
ClaimURL string `json:"claim_url,omitempty"`
}
func toInvitationResponse(inv *models.Invitation, now time.Time) invitationResponse {
resp := invitationResponse{
ID: inv.ID,
Email: inv.Email,
Role: inv.Role,
AccessGroupID: inv.AccessGroupID,
LibraryIDs: inv.LibraryIDs,
CreateProfile: inv.CreateProfile,
ShowTour: inv.ShowTour,
Note: inv.Note,
InvitedBy: inv.InvitedBy,
InvitedByName: inv.InvitedByName,
Status: inv.Status(now),
ExpiresAt: inv.ExpiresAt,
AcceptedUser: inv.AcceptedUserID,
CreatedAt: inv.CreatedAt,
}
if inv.AcceptedAt != nil {
accepted := inv.AcceptedAt.Format(time.RFC3339)
resp.AcceptedAt = &accepted
}
return resp
}
// HandleListInvitations handles GET /admin/invitations.
func (h *AdminInvitationHandler) HandleListInvitations(w http.ResponseWriter, r *http.Request) {
list, err := h.service.List(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list invitations")
return
}
now := time.Now()
resp := make([]invitationResponse, 0, len(list))
for _, inv := range list {
resp = append(resp, toInvitationResponse(inv, now))
}
writeJSON(w, http.StatusOK, resp)
}
// HandleCreateInvitation handles POST /admin/invitations.
func (h *AdminInvitationHandler) HandleCreateInvitation(w http.ResponseWriter, r *http.Request) {
claims := apimw.GetClaims(r.Context())
if claims == nil {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return
}
var req createInvitationRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid request body")
return
}
createProfile := true
if req.CreateProfile != nil {
createProfile = *req.CreateProfile
}
showTour := true
if req.ShowTour != nil {
showTour = *req.ShowTour
}
result, err := h.service.Send(r.Context(), invitations.SendInput{
Email: req.Email,
Role: req.Role,
AccessGroupID: req.AccessGroupID,
LibraryIDs: req.LibraryIDs,
CreateProfile: createProfile,
ShowTour: showTour,
Note: req.Note,
InvitedBy: int64(claims.UserID),
})
if err != nil {
writeInvitationSendError(w, err)
return
}
writeJSON(w, http.StatusCreated, buildSendResponse(result))
}
// HandleResendInvitation handles POST /admin/invitations/{id}/resend.
func (h *AdminInvitationHandler) HandleResendInvitation(w http.ResponseWriter, r *http.Request) {
claims := apimw.GetClaims(r.Context())
if claims == nil {
writeError(w, http.StatusUnauthorized, "unauthorized", "Authentication required")
return
}
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid invitation ID")
return
}
result, err := h.service.Resend(r.Context(), id, int64(claims.UserID))
if err != nil {
if errors.Is(err, invitations.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Invitation not found")
return
}
writeInvitationSendError(w, err)
return
}
writeJSON(w, http.StatusOK, buildSendResponse(result))
}
// HandleRevokeInvitation handles DELETE /admin/invitations/{id}.
func (h *AdminInvitationHandler) HandleRevokeInvitation(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_request", "Invalid invitation ID")
return
}
if err := h.service.Revoke(r.Context(), id); err != nil {
if errors.Is(err, invitations.ErrNotFound) {
writeError(w, http.StatusNotFound, "not_found", "Invitation not found")
return
}
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to revoke invitation")
return
}
w.WriteHeader(http.StatusNoContent)
}
func buildSendResponse(result *invitations.SendResult) sendInvitationResponse {
resp := sendInvitationResponse{
Invitation: toInvitationResponse(result.Invitation, time.Now()),
EmailSent: result.EmailSent,
ClaimURL: result.ClaimURL,
}
return resp
}
func writeInvitationSendError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, invitations.ErrInvalidEmail):
writeError(w, http.StatusBadRequest, "invalid_email", "Invalid email address")
case errors.Is(err, invitations.ErrEmailTaken):
writeError(w, http.StatusConflict, "email_taken", "An account with this email already exists")
case errors.Is(err, invitations.ErrRoleNotAllowed):
writeError(w, http.StatusForbidden, "role_not_allowed", "You may not grant this role")
case errors.Is(err, invitations.ErrNoLinkBase):
writeError(w, http.StatusConflict, "no_link_base",
"Configure notifications.email.external_url (or a server public URL) so invitation links can be built")
default:
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to send invitation")
}
}