feat(panel): implement user management administration panel with sessions and minecraft link support

This commit is contained in:
Lemon-miaow committed 2026-07-04 04:08:14 +08:00
1 parent 83e57b4c45
commit 3347cc05d5
28 files changed
+4263 -68

No files matched your search

+234 -3
View File
@@ -75,6 +75,9 @@ type fakeRepo struct {
// just as the PG query does.
passkeyCreds map[string]PasskeyCredential
passkeyChallenges map[string]*fakePasskeyChallenge
// user admin fakes
seededUsers []seededUser
fakeQuotas map[string]*QuotaView
}
// fakePasskeyChallenge mirrors a webauthn_challenges row: its owner and purpose, the
@@ -155,9 +158,10 @@ func newFakeRepo() *fakeRepo {
otps: map[string]*fakeEmailOTP{},
blacklist: map[string]bool{},
holds: map[string]fakeDataHold{},
passkeyCreds: map[string]PasskeyCredential{},
passkeyChallenges: map[string]*fakePasskeyChallenge{},
}
passkeyCreds: map[string]PasskeyCredential{},
passkeyChallenges: map[string]*fakePasskeyChallenge{},
fakeQuotas: map[string]*QuotaView{},
}
}
func (f *fakeRepo) ServerBySubdomain(_ context.Context, s string) (*ServerRecord, error) {
@@ -631,6 +635,233 @@ func (f *fakeRepo) SetSetting(_ context.Context, key string, value []byte) error
return nil
}
// ---- user admin fakes ----
// seededUser is a test-only user row held in the fake repo.
type seededUser struct {
view UserView
detail UserDetail
}
func (f *fakeRepo) seedUser(u UserView) {
su := &StaffUser{ID: u.ID, Username: u.Username, Email: u.Email, Role: u.Role}
f.staff[u.Username] = su
f.seededUsers = append(f.seededUsers, seededUser{view: u, detail: UserDetail{UserView: u}})
}
func (f *fakeRepo) ListUsers(_ context.Context, opts ListUsersOpts) ([]UserView, int, error) {
var filtered []UserView
for _, su := range f.seededUsers {
u := su.view
if opts.Query != "" {
q := strings.ToLower(opts.Query)
ul := strings.ToLower(u.Username)
el := strings.ToLower(u.Email)
if !strings.Contains(ul, q) && !strings.Contains(el, q) {
continue
}
}
if opts.Role != "" && u.Role != opts.Role {
continue
}
switch opts.Hidden {
case "true":
if !u.Disabled {
continue
}
case "false":
if u.Disabled {
continue
}
}
filtered = append(filtered, u)
}
total := len(filtered)
limit := opts.Limit
if limit <= 0 || limit > 100 {
limit = 20
}
offset := opts.Offset
if offset < 0 {
offset = 0
}
if offset >= len(filtered) {
return []UserView{}, total, nil
}
end := offset + limit
if end > len(filtered) {
end = len(filtered)
}
// Newest first — the PG orders by created_at DESC too.
for i, j := 0, len(filtered)-1; i < j; i, j = i+1, j-1 {
filtered[i], filtered[j] = filtered[j], filtered[i]
}
return filtered[offset:end], total, nil
}
func (f *fakeRepo) UserDetail(_ context.Context, userID string) (*UserDetail, error) {
for _, su := range f.seededUsers {
if su.view.ID == userID {
return &su.detail, nil
}
}
return nil, ErrNotFound
}
func (f *fakeRepo) CreateUser(_ context.Context, input CreateUserInput, _ string) (*UserView, error) {
for _, su := range f.seededUsers {
if su.view.Username == input.Username {
return nil, ErrConflict
}
}
id := "test-" + input.Username
u := UserView{
ID: id, Username: input.Username, Email: input.Email,
Role: input.Role, MustChangePassword: input.MustChange,
CreatedAt: time.Now(), UpdatedAt: time.Now(),
}
d := UserDetail{UserView: u}
f.seededUsers = append(f.seededUsers, seededUser{view: u, detail: d})
f.staff[input.Username] = &StaffUser{ID: u.ID, Username: u.Username, Email: u.Email, Role: u.Role}
return &u, nil
}
func (f *fakeRepo) UpdateUser(_ context.Context, userID string, patch UpdateUserInput, _ string) (*UserView, error) {
for i, su := range f.seededUsers {
if su.view.ID != userID {
continue
}
if patch.Username != nil {
for _, other := range f.seededUsers {
if other.view.ID != userID && other.view.Username == *patch.Username {
return nil, ErrConflict
}
}
f.seededUsers[i].view.Username = *patch.Username
f.seededUsers[i].detail.Username = *patch.Username
}
if patch.Email != nil {
f.seededUsers[i].view.Email = *patch.Email
f.seededUsers[i].detail.Email = *patch.Email
}
if patch.Role != nil {
f.seededUsers[i].view.Role = *patch.Role
f.seededUsers[i].detail.Role = *patch.Role
}
v := f.seededUsers[i].view
return &v, nil
}
return nil, ErrNotFound
}
func (f *fakeRepo) DeleteUser(_ context.Context, userID, _ string) error {
for i, su := range f.seededUsers {
if su.view.ID == userID {
f.seededUsers = append(f.seededUsers[:i], f.seededUsers[i+1:]...)
return nil
}
}
return ErrNotFound
}
func (f *fakeRepo) SetUserDisabled(_ context.Context, userID string, disabled bool) error {
for i, su := range f.seededUsers {
if su.view.ID == userID {
f.seededUsers[i].view.Disabled = disabled
f.seededUsers[i].detail.Disabled = disabled
return nil
}
}
return ErrNotFound
}
func (f *fakeRepo) AdminResetPassword(_ context.Context, userID, passwordHash string) error {
for _, su := range f.seededUsers {
if su.view.ID == userID {
return nil
}
}
return ErrNotFound
}
// ---- quota admin fakes ----
func (f *fakeRepo) GetQuotas(_ context.Context, userID string) (*QuotaView, error) {
v := &QuotaView{UserID: userID}
if f.fakeQuotas == nil {
return v, nil
}
if qv, ok := f.fakeQuotas[userID]; ok {
v.MaxServers = qv.MaxServers
v.MaxCPUMilli = qv.MaxCPUMilli
v.MaxMemoryMB = qv.MaxMemoryMB
v.MaxStorageGB = qv.MaxStorageGB
}
return v, nil
}
func (f *fakeRepo) SetQuotas(_ context.Context, userID string, qi QuotaInput, _ string) (*QuotaView, error) {
if f.fakeQuotas == nil {
f.fakeQuotas = map[string]*QuotaView{}
}
if _, ok := f.fakeQuotas[userID]; !ok {
f.fakeQuotas[userID] = &QuotaView{UserID: userID}
}
if qi.MaxServers != nil {
f.fakeQuotas[userID].MaxServers = qi.MaxServers
}
if qi.MaxCPUMilli != nil {
f.fakeQuotas[userID].MaxCPUMilli = qi.MaxCPUMilli
}
if qi.MaxMemoryMB != nil {
f.fakeQuotas[userID].MaxMemoryMB = qi.MaxMemoryMB
}
if qi.MaxStorageGB != nil {
f.fakeQuotas[userID].MaxStorageGB = qi.MaxStorageGB
}
return f.fakeQuotas[userID], nil
}
// ---- session admin fakes ----
func (f *fakeRepo) ListUserSessions(_ context.Context, userID string, now time.Time) ([]SessionView, error) {
var out []SessionView
for hash, s := range f.sessions {
if s.userID == userID && !s.revoked && s.expiresAt.After(now) {
out = append(out, SessionView{TokenHash: hash, CreatedAt: time.Now(), ExpiresAt: s.expiresAt})
}
}
return out, nil
}
func (f *fakeRepo) RevokeAllUserSessions(_ context.Context, userID string) error {
for _, s := range f.sessions {
if s.userID == userID {
s.revoked = true
}
}
return nil
}
func (f *fakeRepo) UnlinkAccount(_ context.Context, userID, mcUUID string) error {
if f.links[mcUUID] != userID {
return ErrNotFound
}
delete(f.links, mcUUID)
delete(f.linkAuthSource, mcUUID)
return nil
}
func (f *fakeRepo) LinkAccount(_ context.Context, userID, mcUUID, authSource string) error {
if existing, ok := f.links[mcUUID]; ok && existing != userID {
return ErrConflict
}
f.links[mcUUID] = userID
f.linkAuthSource[mcUUID] = authSource
f.linked[userID] = true
return nil
}
// fakeRestorer records the restore it was asked to start and returns a canned
// error, mirroring the Restorer kick-off contract. The real restore Job is
// integration-only, so the handler is tested against this fake (spec §466).