package main import ( "context" "database/sql" "errors" "fmt" "net/http" "atlas9.dev/c/core" "atlas9.dev/c/core/dbi" "atlas9.dev/c/core/iam" "atlas9.dev/c/core/outbox" "atlas9.dev/c/core/routes" ) type DashboardAPI struct { DB *sql.DB Sessions iam.SessionStore Identity *IdentityApi Tenants dbi.Factory[iam.TenantStore] TenantMembers dbi.Factory[iam.TenantMemberStore] Groups dbi.Factory[iam.GroupStore] Roles dbi.Factory[iam.RoleStore] Grants dbi.Factory[iam.GrantStore] Outbox dbi.Factory[outbox.Emitter] } func (d *DashboardAPI) userID(ctx context.Context) (core.ID, error) { id, _ := d.Sessions.Get(ctx) if id.IsZero() { return core.ID{}, ErrNotLoggedIn } return id, nil } // My tenants type DashboardTenant struct { ID core.ID Name string Type iam.TenantType } type DashboardMyTenantsRes struct { Items []DashboardTenant } func (d *DashboardAPI) myTenants(ctx context.Context, req struct{}) (*DashboardMyTenantsRes, error) { userID, err := d.userID(ctx) if err != nil { return nil, err } memberships, err := dbi.ReadOnly(ctx, d.DB, func(tx dbi.DBI) (core.Page[iam.TenantMember], error) { return d.TenantMembers(tx).ListByUser(ctx, userID, core.PageReq{Limit: 100}) }) if err != nil { return nil, err } var items []DashboardTenant for _, m := range memberships.Items { t, err := dbi.ReadOnly(ctx, d.DB, func(tx dbi.DBI) (*iam.Tenant, error) { return d.Tenants(tx).Get(ctx, m.TenantID) }) if err != nil { continue } items = append(items, DashboardTenant{ ID: t.ID, Name: t.Name, Type: t.Type, }) } return &DashboardMyTenantsRes{Items: items}, nil } // Create tenant type DashboardCreateTenantReq struct { Name string } func (d *DashboardAPI) createTenant(ctx context.Context, req DashboardCreateTenantReq) (*DashboardTenant, error) { userID, err := d.userID(ctx) if err != nil { return nil, err } if req.Name == "" { return nil, fmt.Errorf("name is required") } tenant := &iam.Tenant{ ID: core.NewID(), Name: req.Name, Type: iam.TenantOrganization, } _, err = dbi.ReadWrite(ctx, d.DB, func(tx dbi.DBI) (struct{}, error) { if _, err := d.Tenants(tx).Save(ctx, tenant); err != nil { return struct{}{}, err } return struct{}{}, d.TenantMembers(tx).Add(ctx, tenant.ID, userID, iam.MemberActive) }) if err != nil { return nil, err } return &DashboardTenant{ID: tenant.ID, Name: tenant.Name, Type: tenant.Type}, nil } // Get tenant type DashboardGetTenantReq struct { ID core.ID } func (d *DashboardAPI) getTenant(ctx context.Context, req DashboardGetTenantReq) (*DashboardTenant, error) { _, err := d.userID(ctx) if err != nil { return nil, err } t, err := dbi.ReadOnly(ctx, d.DB, func(tx dbi.DBI) (*iam.Tenant, error) { return d.Tenants(tx).Get(ctx, req.ID) }) if err != nil { return nil, err } return &DashboardTenant{ID: t.ID, Name: t.Name, Type: t.Type}, nil } // Members type DashboardListMembersReq struct { TenantID core.ID } func (d *DashboardAPI) listMembers(ctx context.Context, req DashboardListMembersReq) (*ListTenantMembersRes, error) { _, err := d.userID(ctx) if err != nil { return nil, err } page, err := dbi.ReadOnly(ctx, d.DB, func(tx dbi.DBI) (core.Page[iam.TenantMember], error) { return d.TenantMembers(tx).ListByTenant(ctx, req.TenantID, core.PageReq{Limit: 100}) }) if err != nil { return nil, err } res := ListTenantMembersRes(page) return &res, nil } type DashboardInviteMemberReq struct { TenantID core.ID Email string } func (d *DashboardAPI) inviteMember(ctx context.Context, req DashboardInviteMemberReq) (*AdminTenantMemberInfo, error) { _, err := d.userID(ctx) if err != nil { return nil, err } user, err := d.Identity.GetUserByEmail(ctx, req.Email) if errors.Is(err, core.ErrNotFound) { // User doesn't exist yet — store pending invitation + send email _, err = dbi.ReadWrite(ctx, d.DB, func(tx dbi.DBI) (struct{}, error) { if err := NewPendingInvitationStore(tx).Add(ctx, req.TenantID, req.Email); err != nil { return struct{}{}, err } return struct{}{}, EmitTenantInvitation(ctx, d.Outbox(tx), req.TenantID, req.Email) }) if err != nil { return nil, err } return &AdminTenantMemberInfo{ TenantID: req.TenantID, Email: req.Email, Status: iam.MemberInvited, }, nil } if err != nil { return nil, err } _, err = dbi.ReadWrite(ctx, d.DB, func(tx dbi.DBI) (struct{}, error) { err := d.TenantMembers(tx).Add(ctx, req.TenantID, user.ID, iam.MemberInvited) if err != nil { return struct{}{}, err } return struct{}{}, EmitTenantInvitation(ctx, d.Outbox(tx), req.TenantID, user.Email) }) if err != nil { return nil, err } return &AdminTenantMemberInfo{ TenantID: req.TenantID, UserID: user.ID, Email: user.Email, Status: iam.MemberInvited, }, nil } type DashboardRemoveMemberReq struct { TenantID core.ID UserID core.ID } type DashboardRemoveMemberRes struct{} func (d *DashboardAPI) removeMember(ctx context.Context, req DashboardRemoveMemberReq) (*DashboardRemoveMemberRes, error) { _, err := d.userID(ctx) if err != nil { return nil, err } _, err = dbi.ReadWrite(ctx, d.DB, func(tx dbi.DBI) (struct{}, error) { return struct{}{}, d.TenantMembers(tx).Remove(ctx, req.TenantID, req.UserID) }) if err != nil { return nil, err } return &DashboardRemoveMemberRes{}, nil } // Groups func (d *DashboardAPI) listGroups(ctx context.Context, req ListGroupsByNamespaceReq) (*ListGroupsRes, error) { _, err := d.userID(ctx) if err != nil { return nil, err } page, err := dbi.ReadOnly(ctx, d.DB, func(tx dbi.DBI) (core.Page[iam.Group], error) { return d.Groups(tx).ListByNamespace(ctx, req.Namespace, req.Page) }) if err != nil { return nil, err } res := ListGroupsRes(page) return &res, nil } func (d *DashboardAPI) getGroup(ctx context.Context, req GetGroupReq) (*iam.Group, error) { _, err := d.userID(ctx) if err != nil { return nil, err } return dbi.ReadOnly(ctx, d.DB, func(tx dbi.DBI) (*iam.Group, error) { return d.Groups(tx).Get(ctx, req.ID) }) } func (d *DashboardAPI) createGroup(ctx context.Context, req AdminCreateGroupReq) (*iam.Group, error) { _, err := d.userID(ctx) if err != nil { return nil, err } group := iam.Group{ ID: core.NewID(), Path: req.Path, Name: req.Name, } _, err = dbi.ReadWrite(ctx, d.DB, func(tx dbi.DBI) (bool, error) { return d.Groups(tx).Save(ctx, &group) }) if err != nil { return nil, err } return &group, nil } func (d *DashboardAPI) updateGroup(ctx context.Context, req AdminUpdateGroupReq) (*iam.Group, error) { _, err := d.userID(ctx) if err != nil { return nil, err } group := iam.Group{ ID: req.ID, Path: req.Path, Name: req.Name, } _, err = dbi.ReadWrite(ctx, d.DB, func(tx dbi.DBI) (bool, error) { return d.Groups(tx).Save(ctx, &group) }) if err != nil { return nil, err } return &group, nil } func (d *DashboardAPI) deleteGroup(ctx context.Context, req DeleteGroupReq) (*DeleteGroupRes, error) { _, err := d.userID(ctx) if err != nil { return nil, err } _, err = dbi.ReadWrite(ctx, d.DB, func(tx dbi.DBI) (struct{}, error) { return struct{}{}, d.Groups(tx).Delete(ctx, req.ID) }) if err != nil { return nil, err } return &DeleteGroupRes{}, nil } // Roles func (d *DashboardAPI) listRoles(ctx context.Context, req ListRolesByNamespaceReq) (*ListRolesRes, error) { _, err := d.userID(ctx) if err != nil { return nil, err } page, err := dbi.ReadOnly(ctx, d.DB, func(tx dbi.DBI) (core.Page[iam.Role], error) { return d.Roles(tx).ListByNamespace(ctx, req.Namespace, req.Page) }) if err != nil { return nil, err } res := ListRolesRes(page) return &res, nil } // Grants func (d *DashboardAPI) listGrants(ctx context.Context, req ListGrantsByNamespaceReq) (*ListAllGrantsRes, error) { _, err := d.userID(ctx) if err != nil { return nil, err } page, err := dbi.ReadOnly(ctx, d.DB, func(tx dbi.DBI) (core.Page[iam.Grant], error) { return d.Grants(tx).ListByNamespace(ctx, req.Namespace, req.Page) }) if err != nil { return nil, err } res := ListAllGrantsRes(page) return &res, nil } // Pending Invitations type DashboardPendingInvitation struct { TenantID core.ID TenantName string } type DashboardPendingInvitationsRes struct { Items []DashboardPendingInvitation } func (d *DashboardAPI) listPendingInvitations(ctx context.Context, req struct{}) (*DashboardPendingInvitationsRes, error) { _, err := d.userID(ctx) if err != nil { return nil, err } user, _, err := d.Identity.GetProfile(ctx) if err != nil { return nil, err } tenantIDs, err := dbi.ReadOnly(ctx, d.DB, func(tx dbi.DBI) ([]core.ID, error) { return NewPendingInvitationStore(tx).ListByEmail(ctx, user.Email) }) if err != nil { return nil, err } var items []DashboardPendingInvitation for _, tid := range tenantIDs { t, err := dbi.ReadOnly(ctx, d.DB, func(tx dbi.DBI) (*iam.Tenant, error) { return d.Tenants(tx).Get(ctx, tid) }) if err != nil { continue } items = append(items, DashboardPendingInvitation{ TenantID: t.ID, TenantName: t.Name, }) } return &DashboardPendingInvitationsRes{Items: items}, nil } type DashboardAcceptInvitationReq struct { TenantID core.ID } type DashboardAcceptInvitationRes struct{} func (d *DashboardAPI) acceptPendingInvitation(ctx context.Context, req DashboardAcceptInvitationReq) (*DashboardAcceptInvitationRes, error) { userID, err := d.userID(ctx) if err != nil { return nil, err } user, _, err := d.Identity.GetProfile(ctx) if err != nil { return nil, err } _, err = dbi.ReadWrite(ctx, d.DB, func(tx dbi.DBI) (struct{}, error) { if err := d.TenantMembers(tx).Add(ctx, req.TenantID, userID, iam.MemberActive); err != nil { return struct{}{}, err } return struct{}{}, NewPendingInvitationStore(tx).Delete(ctx, req.TenantID, user.Email) }) if err != nil { return nil, err } return &DashboardAcceptInvitationRes{}, nil } // Profile type DashboardProfileRes struct { Email string Name string Bio string Location string Website string PictureURL string } func (d *DashboardAPI) getProfile(ctx context.Context, req struct{}) (*DashboardProfileRes, error) { user, profile, err := d.Identity.GetProfile(ctx) if err != nil { return nil, err } return &DashboardProfileRes{ Email: user.Email, Name: profile.Name, Bio: profile.Bio, Location: profile.Location, Website: profile.Website, PictureURL: profile.PictureURL, }, nil } type DashboardUpdateProfileReq struct { Name string Bio string Location string Website string PictureURL string } type DashboardUpdateProfileRes struct{} func (d *DashboardAPI) updateProfile(ctx context.Context, req DashboardUpdateProfileReq) (*DashboardUpdateProfileRes, error) { err := d.Identity.UpdateProfile(ctx, &Profile{ Name: req.Name, Bio: req.Bio, Location: req.Location, Website: req.Website, PictureURL: req.PictureURL, }) if err != nil { return nil, err } return &DashboardUpdateProfileRes{}, nil } func DashboardRoutes(api *DashboardAPI) []routes.Route { return []routes.Route{ routes.HTTP("GET /dashboard/mithril.js", func(w http.ResponseWriter, r *http.Request) { http.ServeFileFS(w, r, adminStatic, "static/mithril.js") }), routes.HTTP("GET /dashboard/dashboard.css", func(w http.ResponseWriter, r *http.Request) { http.ServeFileFS(w, r, adminStatic, "static/dashboard.css") }), routes.HTTP("GET /dashboard/dashboard.js", func(w http.ResponseWriter, r *http.Request) { http.ServeFileFS(w, r, adminStatic, "static/dashboard.js") }), routes.HTTP("GET /dashboard/", func(w http.ResponseWriter, r *http.Request) { http.ServeFileFS(w, r, adminStatic, "static/dashboard.html") }), routes.RPC("/dashboard/api/my-tenants", api.myTenants), routes.RPC("/dashboard/api/tenants/create", api.createTenant), routes.RPC("/dashboard/api/tenants/get", api.getTenant), routes.RPC("/dashboard/api/members/list", api.listMembers), routes.RPC("/dashboard/api/members/invite", api.inviteMember), routes.RPC("/dashboard/api/members/remove", api.removeMember), routes.RPC("/dashboard/api/groups/list", api.listGroups), routes.RPC("/dashboard/api/groups/get", api.getGroup), routes.RPC("/dashboard/api/groups/create", api.createGroup), routes.RPC("/dashboard/api/groups/update", api.updateGroup), routes.RPC("/dashboard/api/groups/delete", api.deleteGroup), routes.RPC("/dashboard/api/roles/list", api.listRoles), routes.RPC("/dashboard/api/grants/list", api.listGrants), routes.RPC("/dashboard/api/invitations/list", api.listPendingInvitations), routes.RPC("/dashboard/api/invitations/accept", api.acceptPendingInvitation), routes.RPC("/dashboard/api/profile/get", api.getProfile), routes.RPC("/dashboard/api/profile/update", api.updateProfile), } }