* 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>
212 lines
6.9 KiB
Go
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")
|
|
}
|
|
}
|