@ysk-kit/cli 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/add-capability.d.ts +4 -0
- package/dist/add-capability.d.ts.map +1 -0
- package/dist/add-capability.js +104 -0
- package/dist/add-capability.js.map +1 -0
- package/dist/add-module.d.ts +5 -0
- package/dist/add-module.d.ts.map +1 -0
- package/dist/add-module.js +210 -0
- package/dist/add-module.js.map +1 -0
- package/dist/agent-stubs.d.ts +3 -0
- package/dist/agent-stubs.d.ts.map +1 -0
- package/dist/agent-stubs.js +25 -0
- package/dist/agent-stubs.js.map +1 -0
- package/dist/apply-capability.d.ts +3 -0
- package/dist/apply-capability.d.ts.map +1 -0
- package/dist/apply-capability.js +189 -0
- package/dist/apply-capability.js.map +1 -0
- package/dist/capability-catalog.d.ts +25 -0
- package/dist/capability-catalog.d.ts.map +1 -0
- package/dist/capability-catalog.js +123 -0
- package/dist/capability-catalog.js.map +1 -0
- package/dist/capability-patches.d.ts +40 -0
- package/dist/capability-patches.d.ts.map +1 -0
- package/dist/capability-patches.js +616 -0
- package/dist/capability-patches.js.map +1 -0
- package/dist/check-agent.d.ts +9 -0
- package/dist/check-agent.d.ts.map +1 -0
- package/dist/check-agent.js +85 -0
- package/dist/check-agent.js.map +1 -0
- package/dist/generate-openapi.d.ts +2 -0
- package/dist/generate-openapi.d.ts.map +1 -0
- package/dist/generate-openapi.js +52 -0
- package/dist/generate-openapi.js.map +1 -0
- package/dist/help.d.ts +2 -0
- package/dist/help.d.ts.map +1 -0
- package/dist/help.js +54 -0
- package/dist/help.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +59 -0
- package/dist/index.js.map +1 -0
- package/dist/merge-prisma.d.ts +15 -0
- package/dist/merge-prisma.d.ts.map +1 -0
- package/dist/merge-prisma.js +90 -0
- package/dist/merge-prisma.js.map +1 -0
- package/dist/patch-text.d.ts +17 -0
- package/dist/patch-text.d.ts.map +1 -0
- package/dist/patch-text.js +72 -0
- package/dist/patch-text.js.map +1 -0
- package/dist/upgrade.d.ts +16 -0
- package/dist/upgrade.d.ts.map +1 -0
- package/dist/upgrade.js +110 -0
- package/dist/upgrade.js.map +1 -0
- package/package.json +41 -0
- package/templates/agent/skills/add-capability/SKILL.md +8 -0
- package/templates/agent/skills/add-module/SKILL.md +8 -0
- package/templates/agent/skills/envelope-api/SKILL.md +8 -0
- package/templates/agent/skills/fix-layers/SKILL.md +8 -0
- package/templates/agent/skills/new-product/SKILL.md +8 -0
- package/templates/agent/skills/verify-change/SKILL.md +8 -0
- package/templates/agent/ysk-kit.mdc +6 -0
- package/templates/capabilities/billing/apps/api/src/modules/billing/application/billing-service.ts +140 -0
- package/templates/capabilities/billing/apps/api/src/modules/billing/domain/billing-port.ts +23 -0
- package/templates/capabilities/billing/apps/api/src/modules/billing/domain/subscription-repository.ts +22 -0
- package/templates/capabilities/billing/apps/api/src/modules/billing/infra/billing-router.ts +85 -0
- package/templates/capabilities/billing/apps/api/src/modules/billing/infra/billing.app.test.ts +273 -0
- package/templates/capabilities/billing/apps/api/src/modules/billing/infra/log-billing.ts +31 -0
- package/templates/capabilities/billing/apps/api/src/modules/billing/infra/memory-subscription-repository.ts +27 -0
- package/templates/capabilities/billing/apps/api/src/modules/billing/infra/prisma-subscription-repository.ts +52 -0
- package/templates/capabilities/billing/apps/api/src/modules/billing/infra/stripe-billing.test.ts +127 -0
- package/templates/capabilities/billing/apps/api/src/modules/billing/infra/stripe-billing.ts +160 -0
- package/templates/capabilities/billing/apps/web/src/features/orgs/billing-page.tsx +183 -0
- package/templates/capabilities/billing/packages/contracts/src/api/billing.ts +85 -0
- package/templates/capabilities/billing/packages/contracts/src/dto/billing.ts +59 -0
- package/templates/capabilities/billing/packages/sdk/src/resources/billing.ts +38 -0
- package/templates/capabilities/billing/packages/web-sdk/src/billing-hooks.ts +56 -0
- package/templates/capabilities/llm/apps/api/src/modules/llm/application/llm-service.ts +48 -0
- package/templates/capabilities/llm/apps/api/src/modules/llm/domain/usage-repository.ts +10 -0
- package/templates/capabilities/llm/apps/api/src/modules/llm/infra/llm-router.ts +88 -0
- package/templates/capabilities/llm/apps/api/src/modules/llm/infra/llm.app.test.ts +89 -0
- package/templates/capabilities/llm/apps/api/src/modules/llm/infra/memory-usage-repository.ts +13 -0
- package/templates/capabilities/llm/apps/api/src/modules/llm/infra/prisma-usage-repository.ts +17 -0
- package/templates/capabilities/llm/apps/web/src/features/llm/llm-page.tsx +53 -0
- package/templates/capabilities/llm/packages/contracts/src/api/llm.ts +26 -0
- package/templates/capabilities/llm/packages/contracts/src/dto/llm.ts +33 -0
- package/templates/capabilities/llm/packages/sdk/src/resources/llm.ts +68 -0
- package/templates/capabilities/llm/packages/web-sdk/src/llm-hooks.ts +12 -0
- package/templates/capabilities/push/apps/api/src/modules/devices/application/device-service.ts +42 -0
- package/templates/capabilities/push/apps/api/src/modules/devices/domain/device-repository.ts +22 -0
- package/templates/capabilities/push/apps/api/src/modules/devices/infra/device-router.ts +42 -0
- package/templates/capabilities/push/apps/api/src/modules/devices/infra/device.app.test.ts +34 -0
- package/templates/capabilities/push/apps/api/src/modules/devices/infra/memory-device-repository.ts +33 -0
- package/templates/capabilities/push/apps/api/src/modules/devices/infra/prisma-device-repository.ts +42 -0
- package/templates/capabilities/push/apps/mobile/src/adapters/push.ts +21 -0
- package/templates/capabilities/push/packages/contracts/src/api/devices.ts +33 -0
- package/templates/capabilities/push/packages/contracts/src/dto/device.ts +18 -0
- package/templates/capabilities/push/packages/sdk/src/resources/devices.ts +10 -0
- package/templates/capabilities/team/apps/api/src/modules/organizations/application/organization-service.ts +272 -0
- package/templates/capabilities/team/apps/api/src/modules/organizations/domain/organization-repository.ts +58 -0
- package/templates/capabilities/team/apps/api/src/modules/organizations/infra/memory-organization-repository.ts +141 -0
- package/templates/capabilities/team/apps/api/src/modules/organizations/infra/organization-router.ts +130 -0
- package/templates/capabilities/team/apps/api/src/modules/organizations/infra/organization.app.test.ts +213 -0
- package/templates/capabilities/team/apps/api/src/modules/organizations/infra/prisma-organization-repository.ts +142 -0
- package/templates/capabilities/team/apps/mobile/src/screens/invite-screen.tsx +40 -0
- package/templates/capabilities/team/apps/mobile/src/screens/org-detail-screen.tsx +154 -0
- package/templates/capabilities/team/apps/mobile/src/screens/orgs-screen.tsx +53 -0
- package/templates/capabilities/team/apps/web/src/features/orgs/invite-page.tsx +77 -0
- package/templates/capabilities/team/apps/web/src/features/orgs/org-detail-page.tsx +138 -0
- package/templates/capabilities/team/apps/web/src/features/orgs/orgs-page.tsx +73 -0
- package/templates/capabilities/team/packages/contracts/src/api/organizations.ts +154 -0
- package/templates/capabilities/team/packages/contracts/src/dto/organization.ts +58 -0
- package/templates/capabilities/team/packages/sdk/src/resources/organizations.ts +53 -0
- package/templates/capabilities/team/packages/web-sdk/src/organizations-hooks.ts +80 -0
- package/templates/module/api.ts.tmpl +32 -0
- package/templates/module/application-service.ts.tmpl +9 -0
- package/templates/module/domain-repository.ts.tmpl +15 -0
- package/templates/module/dto.ts.tmpl +21 -0
- package/templates/module/memory-repository.ts.tmpl +36 -0
- package/templates/module/model.prisma.tmpl +8 -0
- package/templates/module/prisma-repository.ts.tmpl +38 -0
- package/templates/module/router.ts.tmpl +32 -0
- package/templates/module/sdk-resource.ts.tmpl +17 -0
- package/templates/module/service.test.ts.tmpl +33 -0
- package/templates/module/web-hooks.ts.tmpl +24 -0
- package/templates/module/web-page.tsx.tmpl +69 -0
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import request from 'supertest';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { createApp } from '../../../app';
|
|
4
|
+
import { createMemoryInput } from '../../../create-memory-input';
|
|
5
|
+
|
|
6
|
+
describe('organizations http', () => {
|
|
7
|
+
it('creates an org with the actor as owner', async () => {
|
|
8
|
+
const app = createApp(createMemoryInput().input);
|
|
9
|
+
const registered = await request(app).post('/v1/auth/register').send({
|
|
10
|
+
email: 'owner@ysk.hk',
|
|
11
|
+
password: 'password1',
|
|
12
|
+
displayName: 'Owner',
|
|
13
|
+
});
|
|
14
|
+
const token = registered.body.data.accessToken as string;
|
|
15
|
+
const created = await request(app)
|
|
16
|
+
.post('/v1/organizations')
|
|
17
|
+
.set('authorization', `Bearer ${token}`)
|
|
18
|
+
.send({ name: 'YSK' });
|
|
19
|
+
expect(created.status).toBe(201);
|
|
20
|
+
const list = await request(app)
|
|
21
|
+
.get('/v1/organizations')
|
|
22
|
+
.set('authorization', `Bearer ${token}`);
|
|
23
|
+
expect(list.body.data).toHaveLength(1);
|
|
24
|
+
const members = await request(app)
|
|
25
|
+
.get(`/v1/organizations/${created.body.data.id}/members`)
|
|
26
|
+
.set('authorization', `Bearer ${token}`);
|
|
27
|
+
expect(members.body.data[0]?.role).toBe('OWNER');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('rejects org list without a token', async () => {
|
|
31
|
+
const app = createApp(createMemoryInput().input);
|
|
32
|
+
const res = await request(app).get('/v1/organizations');
|
|
33
|
+
expect(res.status).toBe(401);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('invites an existing user who accepts with the token only', async () => {
|
|
37
|
+
const mem = createMemoryInput();
|
|
38
|
+
const app = createApp(mem.input);
|
|
39
|
+
const ownerRes = await request(app).post('/v1/auth/register').send({
|
|
40
|
+
email: 'owner@ysk.hk',
|
|
41
|
+
password: 'password1',
|
|
42
|
+
displayName: 'Owner',
|
|
43
|
+
});
|
|
44
|
+
await request(app).post('/v1/auth/register').send({
|
|
45
|
+
email: 'member@ysk.hk',
|
|
46
|
+
password: 'password1',
|
|
47
|
+
displayName: 'Member',
|
|
48
|
+
});
|
|
49
|
+
const ownerToken = ownerRes.body.data.accessToken as string;
|
|
50
|
+
const org = await request(app)
|
|
51
|
+
.post('/v1/organizations')
|
|
52
|
+
.set('authorization', `Bearer ${ownerToken}`)
|
|
53
|
+
.send({ name: 'YSK' });
|
|
54
|
+
const invited = await request(app)
|
|
55
|
+
.post(`/v1/organizations/${org.body.data.id}/invites`)
|
|
56
|
+
.set('authorization', `Bearer ${ownerToken}`)
|
|
57
|
+
.send({ email: 'member@ysk.hk', role: 'MEMBER' });
|
|
58
|
+
expect(invited.status).toBe(200);
|
|
59
|
+
expect(JSON.stringify(invited.body)).not.toContain('token=');
|
|
60
|
+
const pending = await request(app)
|
|
61
|
+
.get(`/v1/organizations/${org.body.data.id}/invites`)
|
|
62
|
+
.set('authorization', `Bearer ${ownerToken}`);
|
|
63
|
+
expect(JSON.stringify(pending.body)).not.toContain('token=');
|
|
64
|
+
const inviteJob = mem.jobs.find(
|
|
65
|
+
(job) => job.name === 'email.send' && job.payload.vars?.inviteUrl,
|
|
66
|
+
);
|
|
67
|
+
const inviteToken = new URL(inviteJob?.payload.vars?.inviteUrl ?? 'http://x').searchParams.get(
|
|
68
|
+
'token',
|
|
69
|
+
);
|
|
70
|
+
expect(inviteToken).toBeTruthy();
|
|
71
|
+
const accepted = await request(app)
|
|
72
|
+
.post('/v1/organizations/invites/accept')
|
|
73
|
+
.send({ token: inviteToken });
|
|
74
|
+
expect(accepted.status).toBe(200);
|
|
75
|
+
const memberLogin = await request(app).post('/v1/auth/login').send({
|
|
76
|
+
email: 'member@ysk.hk',
|
|
77
|
+
password: 'password1',
|
|
78
|
+
});
|
|
79
|
+
const memberToken = memberLogin.body.data.accessToken as string;
|
|
80
|
+
const members = await request(app)
|
|
81
|
+
.get(`/v1/organizations/${org.body.data.id}/members`)
|
|
82
|
+
.set('authorization', `Bearer ${memberToken}`);
|
|
83
|
+
expect(members.body.data.some((row: { email: string }) => row.email === 'member@ysk.hk')).toBe(
|
|
84
|
+
true,
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('invites an unknown email then accepts with a password', async () => {
|
|
89
|
+
const mem = createMemoryInput();
|
|
90
|
+
const app = createApp(mem.input);
|
|
91
|
+
const ownerRes = await request(app).post('/v1/auth/register').send({
|
|
92
|
+
email: 'owner@ysk.hk',
|
|
93
|
+
password: 'password1',
|
|
94
|
+
displayName: 'Owner',
|
|
95
|
+
});
|
|
96
|
+
const ownerToken = ownerRes.body.data.accessToken as string;
|
|
97
|
+
const org = await request(app)
|
|
98
|
+
.post('/v1/organizations')
|
|
99
|
+
.set('authorization', `Bearer ${ownerToken}`)
|
|
100
|
+
.send({ name: 'YSK' });
|
|
101
|
+
await request(app)
|
|
102
|
+
.post(`/v1/organizations/${org.body.data.id}/invites`)
|
|
103
|
+
.set('authorization', `Bearer ${ownerToken}`)
|
|
104
|
+
.send({ email: 'new@ysk.hk', role: 'ADMIN' });
|
|
105
|
+
const invitedUser = await mem.users.findByEmail('new@ysk.hk');
|
|
106
|
+
expect(invitedUser?.status).toBe('INVITED');
|
|
107
|
+
const conflict = await request(app).post('/v1/auth/register').send({
|
|
108
|
+
email: 'new@ysk.hk',
|
|
109
|
+
password: 'password1',
|
|
110
|
+
displayName: 'New',
|
|
111
|
+
});
|
|
112
|
+
expect(conflict.status).toBe(409);
|
|
113
|
+
const inviteJob = mem.jobs.find(
|
|
114
|
+
(job) => job.name === 'email.send' && job.payload.vars?.inviteUrl,
|
|
115
|
+
);
|
|
116
|
+
const inviteToken = new URL(inviteJob?.payload.vars?.inviteUrl ?? 'http://x').searchParams.get(
|
|
117
|
+
'token',
|
|
118
|
+
);
|
|
119
|
+
const accepted = await request(app)
|
|
120
|
+
.post('/v1/organizations/invites/accept')
|
|
121
|
+
.send({ token: inviteToken, password: 'password1', displayName: 'New' });
|
|
122
|
+
expect(accepted.status).toBe(200);
|
|
123
|
+
const login = await request(app).post('/v1/auth/login').send({
|
|
124
|
+
email: 'new@ysk.hk',
|
|
125
|
+
password: 'password1',
|
|
126
|
+
});
|
|
127
|
+
expect(login.status).toBe(200);
|
|
128
|
+
expect(login.body.data.user.status).toBe('ACTIVE');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('forbids members from inviting and protects the last owner', async () => {
|
|
132
|
+
const mem = createMemoryInput();
|
|
133
|
+
const app = createApp(mem.input);
|
|
134
|
+
const ownerRes = await request(app).post('/v1/auth/register').send({
|
|
135
|
+
email: 'owner@ysk.hk',
|
|
136
|
+
password: 'password1',
|
|
137
|
+
displayName: 'Owner',
|
|
138
|
+
});
|
|
139
|
+
const memberRes = await request(app).post('/v1/auth/register').send({
|
|
140
|
+
email: 'member@ysk.hk',
|
|
141
|
+
password: 'password1',
|
|
142
|
+
displayName: 'Member',
|
|
143
|
+
});
|
|
144
|
+
const ownerToken = ownerRes.body.data.accessToken as string;
|
|
145
|
+
const memberUser = await mem.users.findByEmail('member@ysk.hk');
|
|
146
|
+
const org = await request(app)
|
|
147
|
+
.post('/v1/organizations')
|
|
148
|
+
.set('authorization', `Bearer ${ownerToken}`)
|
|
149
|
+
.send({ name: 'YSK' });
|
|
150
|
+
await request(app)
|
|
151
|
+
.post(`/v1/organizations/${org.body.data.id}/invites`)
|
|
152
|
+
.set('authorization', `Bearer ${ownerToken}`)
|
|
153
|
+
.send({ email: 'member@ysk.hk', role: 'MEMBER' });
|
|
154
|
+
const inviteJob = mem.jobs.find(
|
|
155
|
+
(job) => job.name === 'email.send' && job.payload.vars?.inviteUrl,
|
|
156
|
+
);
|
|
157
|
+
const inviteToken = new URL(inviteJob?.payload.vars?.inviteUrl ?? 'http://x').searchParams.get(
|
|
158
|
+
'token',
|
|
159
|
+
);
|
|
160
|
+
await request(app).post('/v1/organizations/invites/accept').send({ token: inviteToken });
|
|
161
|
+
const memberToken = memberRes.body.data.accessToken as string;
|
|
162
|
+
const inviteAttempt = await request(app)
|
|
163
|
+
.post(`/v1/organizations/${org.body.data.id}/invites`)
|
|
164
|
+
.set('authorization', `Bearer ${memberToken}`)
|
|
165
|
+
.send({ email: 'other@ysk.hk', role: 'MEMBER' });
|
|
166
|
+
expect(inviteAttempt.status).toBe(403);
|
|
167
|
+
const leave = await request(app)
|
|
168
|
+
.post(`/v1/organizations/${org.body.data.id}/leave`)
|
|
169
|
+
.set('authorization', `Bearer ${ownerToken}`)
|
|
170
|
+
.send({});
|
|
171
|
+
expect(leave.status).toBe(403);
|
|
172
|
+
const removeOwner = await request(app)
|
|
173
|
+
.delete(`/v1/organizations/${org.body.data.id}/members/${ownerRes.body.data.user.id}`)
|
|
174
|
+
.set('authorization', `Bearer ${ownerToken}`);
|
|
175
|
+
expect(removeOwner.status).toBe(403);
|
|
176
|
+
const removed = await request(app)
|
|
177
|
+
.delete(`/v1/organizations/${org.body.data.id}/members/${memberUser?.id}`)
|
|
178
|
+
.set('authorization', `Bearer ${ownerToken}`);
|
|
179
|
+
expect(removed.status).toBe(200);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('rejects expired or consumed invite tokens', async () => {
|
|
183
|
+
const mem = createMemoryInput();
|
|
184
|
+
const app = createApp(mem.input);
|
|
185
|
+
const ownerRes = await request(app).post('/v1/auth/register').send({
|
|
186
|
+
email: 'owner@ysk.hk',
|
|
187
|
+
password: 'password1',
|
|
188
|
+
displayName: 'Owner',
|
|
189
|
+
});
|
|
190
|
+
const ownerToken = ownerRes.body.data.accessToken as string;
|
|
191
|
+
const org = await request(app)
|
|
192
|
+
.post('/v1/organizations')
|
|
193
|
+
.set('authorization', `Bearer ${ownerToken}`)
|
|
194
|
+
.send({ name: 'YSK' });
|
|
195
|
+
await request(app)
|
|
196
|
+
.post(`/v1/organizations/${org.body.data.id}/invites`)
|
|
197
|
+
.set('authorization', `Bearer ${ownerToken}`)
|
|
198
|
+
.send({ email: 'late@ysk.hk', role: 'MEMBER' });
|
|
199
|
+
const inviteJob = mem.jobs.find(
|
|
200
|
+
(job) => job.name === 'email.send' && job.payload.vars?.inviteUrl,
|
|
201
|
+
);
|
|
202
|
+
const inviteToken = new URL(inviteJob?.payload.vars?.inviteUrl ?? 'http://x').searchParams.get(
|
|
203
|
+
'token',
|
|
204
|
+
);
|
|
205
|
+
await request(app)
|
|
206
|
+
.post('/v1/organizations/invites/accept')
|
|
207
|
+
.send({ token: inviteToken, password: 'password1' });
|
|
208
|
+
const reused = await request(app)
|
|
209
|
+
.post('/v1/organizations/invites/accept')
|
|
210
|
+
.send({ token: inviteToken, password: 'password1' });
|
|
211
|
+
expect(reused.status).toBe(401);
|
|
212
|
+
});
|
|
213
|
+
});
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { InviteOrgRole } from '@ysk-kit/contracts';
|
|
2
|
+
import type { Organization, OrgInvite, PrismaClient } from '../../../generated/prisma/client';
|
|
3
|
+
import type {
|
|
4
|
+
IOrganizationRepository,
|
|
5
|
+
OrganizationRecord,
|
|
6
|
+
OrgInviteRecord,
|
|
7
|
+
} from '../domain/organization-repository';
|
|
8
|
+
|
|
9
|
+
const toOrg = (row: Organization): OrganizationRecord => ({
|
|
10
|
+
id: row.id,
|
|
11
|
+
name: row.name,
|
|
12
|
+
stripeCustomerId: row.stripeCustomerId,
|
|
13
|
+
createdAt: row.createdAt,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const toInviteRole = (role: OrgInvite['role']): InviteOrgRole =>
|
|
17
|
+
role === 'ADMIN' ? 'ADMIN' : 'MEMBER';
|
|
18
|
+
|
|
19
|
+
const toInvite = (row: OrgInvite): OrgInviteRecord => ({
|
|
20
|
+
id: row.id,
|
|
21
|
+
organizationId: row.organizationId,
|
|
22
|
+
email: row.email,
|
|
23
|
+
role: toInviteRole(row.role),
|
|
24
|
+
tokenHash: row.tokenHash,
|
|
25
|
+
expiresAt: row.expiresAt,
|
|
26
|
+
acceptedAt: row.acceptedAt,
|
|
27
|
+
invitedById: row.invitedById,
|
|
28
|
+
createdAt: row.createdAt,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export const createPrismaOrganizationRepository = (
|
|
32
|
+
prisma: PrismaClient,
|
|
33
|
+
): IOrganizationRepository => ({
|
|
34
|
+
async createWithOwner(input) {
|
|
35
|
+
const row = await prisma.organization.create({
|
|
36
|
+
data: {
|
|
37
|
+
name: input.name,
|
|
38
|
+
members: { create: { userId: input.ownerUserId, role: 'OWNER' } },
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
return toOrg(row);
|
|
42
|
+
},
|
|
43
|
+
async findById(id) {
|
|
44
|
+
const row = await prisma.organization.findUnique({ where: { id } });
|
|
45
|
+
return row ? toOrg(row) : null;
|
|
46
|
+
},
|
|
47
|
+
async listForUser(userId) {
|
|
48
|
+
const rows = await prisma.organization.findMany({
|
|
49
|
+
where: { members: { some: { userId } } },
|
|
50
|
+
orderBy: { createdAt: 'desc' },
|
|
51
|
+
});
|
|
52
|
+
return rows.map(toOrg);
|
|
53
|
+
},
|
|
54
|
+
async updateName(id, name) {
|
|
55
|
+
const row = await prisma.organization.update({ where: { id }, data: { name } });
|
|
56
|
+
return toOrg(row);
|
|
57
|
+
},
|
|
58
|
+
async setStripeCustomerId(id, customerId) {
|
|
59
|
+
const row = await prisma.organization.update({
|
|
60
|
+
where: { id },
|
|
61
|
+
data: { stripeCustomerId: customerId },
|
|
62
|
+
});
|
|
63
|
+
return toOrg(row);
|
|
64
|
+
},
|
|
65
|
+
async findMembership(organizationId, userId) {
|
|
66
|
+
return prisma.membership.findUnique({
|
|
67
|
+
where: { organizationId_userId: { organizationId, userId } },
|
|
68
|
+
});
|
|
69
|
+
},
|
|
70
|
+
async listMembers(organizationId) {
|
|
71
|
+
return prisma.membership.findMany({
|
|
72
|
+
where: { organizationId },
|
|
73
|
+
orderBy: { createdAt: 'asc' },
|
|
74
|
+
});
|
|
75
|
+
},
|
|
76
|
+
async addMembership(input) {
|
|
77
|
+
return prisma.membership.create({ data: input });
|
|
78
|
+
},
|
|
79
|
+
async removeMembership(organizationId, userId) {
|
|
80
|
+
try {
|
|
81
|
+
await prisma.membership.delete({
|
|
82
|
+
where: { organizationId_userId: { organizationId, userId } },
|
|
83
|
+
});
|
|
84
|
+
return true;
|
|
85
|
+
} catch {
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
async countOwners(organizationId) {
|
|
90
|
+
return prisma.membership.count({ where: { organizationId, role: 'OWNER' } });
|
|
91
|
+
},
|
|
92
|
+
async upsertPendingInvite(input) {
|
|
93
|
+
const email = input.email.toLowerCase();
|
|
94
|
+
const existing = await prisma.orgInvite.findFirst({
|
|
95
|
+
where: { organizationId: input.organizationId, email, acceptedAt: null },
|
|
96
|
+
});
|
|
97
|
+
if (existing) {
|
|
98
|
+
const row = await prisma.orgInvite.update({
|
|
99
|
+
where: { id: existing.id },
|
|
100
|
+
data: {
|
|
101
|
+
role: input.role,
|
|
102
|
+
tokenHash: input.tokenHash,
|
|
103
|
+
expiresAt: input.expiresAt,
|
|
104
|
+
invitedById: input.invitedById,
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
return toInvite(row);
|
|
108
|
+
}
|
|
109
|
+
const row = await prisma.orgInvite.create({
|
|
110
|
+
data: { ...input, email },
|
|
111
|
+
});
|
|
112
|
+
return toInvite(row);
|
|
113
|
+
},
|
|
114
|
+
async findPendingInviteByEmail(organizationId, email) {
|
|
115
|
+
const row = await prisma.orgInvite.findFirst({
|
|
116
|
+
where: { organizationId, email: email.toLowerCase(), acceptedAt: null },
|
|
117
|
+
});
|
|
118
|
+
return row ? toInvite(row) : null;
|
|
119
|
+
},
|
|
120
|
+
async findInviteByTokenHash(tokenHash) {
|
|
121
|
+
const row = await prisma.orgInvite.findUnique({ where: { tokenHash } });
|
|
122
|
+
return row ? toInvite(row) : null;
|
|
123
|
+
},
|
|
124
|
+
async listPendingInvites(organizationId) {
|
|
125
|
+
const rows = await prisma.orgInvite.findMany({
|
|
126
|
+
where: { organizationId, acceptedAt: null },
|
|
127
|
+
orderBy: { createdAt: 'desc' },
|
|
128
|
+
});
|
|
129
|
+
return rows.map(toInvite);
|
|
130
|
+
},
|
|
131
|
+
async markInviteAccepted(id, at) {
|
|
132
|
+
await prisma.orgInvite.update({ where: { id }, data: { acceptedAt: at } });
|
|
133
|
+
},
|
|
134
|
+
async deleteInvite(id, organizationId) {
|
|
135
|
+
const row = await prisma.orgInvite.findFirst({
|
|
136
|
+
where: { id, organizationId, acceptedAt: null },
|
|
137
|
+
});
|
|
138
|
+
if (!row) return false;
|
|
139
|
+
await prisma.orgInvite.delete({ where: { id } });
|
|
140
|
+
return true;
|
|
141
|
+
},
|
|
142
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { AcceptInviteCommandSchema } from '@ysk-kit/contracts';
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Button, Text, TextInput, View } from 'react-native';
|
|
4
|
+
import { api } from '../lib/client';
|
|
5
|
+
|
|
6
|
+
export function InviteScreen({ onDone }: { onDone: () => void }) {
|
|
7
|
+
const [token, setToken] = useState('');
|
|
8
|
+
const [password, setPassword] = useState('');
|
|
9
|
+
const [displayName, setDisplayName] = useState('');
|
|
10
|
+
const [error, setError] = useState<string | null>(null);
|
|
11
|
+
|
|
12
|
+
const submit = () => {
|
|
13
|
+
const parsed = AcceptInviteCommandSchema.safeParse({
|
|
14
|
+
token,
|
|
15
|
+
...(password.length > 0 ? { password } : {}),
|
|
16
|
+
...(displayName.length > 0 ? { displayName } : {}),
|
|
17
|
+
});
|
|
18
|
+
if (!parsed.success) {
|
|
19
|
+
setError(parsed.error.issues[0]?.message ?? 'Invalid');
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
setError(null);
|
|
23
|
+
api.organizations
|
|
24
|
+
.acceptInvite(parsed.data)
|
|
25
|
+
.then(() => onDone())
|
|
26
|
+
.catch((err: Error) => setError(err.message));
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<View>
|
|
31
|
+
<Button title="Back" onPress={onDone} />
|
|
32
|
+
<Text>Accept invite</Text>
|
|
33
|
+
<TextInput autoCapitalize="none" onChangeText={setToken} value={token} />
|
|
34
|
+
<TextInput secureTextEntry onChangeText={setPassword} value={password} />
|
|
35
|
+
<TextInput onChangeText={setDisplayName} value={displayName} />
|
|
36
|
+
<Button title="Accept" onPress={submit} />
|
|
37
|
+
{error ? <Text>{error}</Text> : null}
|
|
38
|
+
</View>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import {
|
|
2
|
+
InviteMemberCommandSchema,
|
|
3
|
+
type MembershipDto,
|
|
4
|
+
type OrganizationDto,
|
|
5
|
+
type OrgInviteDto,
|
|
6
|
+
type UserDto,
|
|
7
|
+
} from '@ysk-kit/contracts';
|
|
8
|
+
import { orgRoleCan } from '@ysk-kit/ui-logic';
|
|
9
|
+
import { useEffect, useState } from 'react';
|
|
10
|
+
import { Button, Text, TextInput, View } from 'react-native';
|
|
11
|
+
import { api } from '../lib/client';
|
|
12
|
+
|
|
13
|
+
const fetchOrgDetail = (organizationId: string) =>
|
|
14
|
+
Promise.all([
|
|
15
|
+
api.auth.me(),
|
|
16
|
+
api.organizations.get(organizationId),
|
|
17
|
+
api.organizations.members(organizationId),
|
|
18
|
+
]).then(([meUser, orgDto, memberList]) => {
|
|
19
|
+
const role = memberList.find((row) => row.userId === meUser.id)?.role;
|
|
20
|
+
if (role && orgRoleCan(role, 'org.invite')) {
|
|
21
|
+
return api.organizations.invites(organizationId).then((inviteList) => ({
|
|
22
|
+
me: meUser,
|
|
23
|
+
org: orgDto,
|
|
24
|
+
members: memberList,
|
|
25
|
+
invites: inviteList,
|
|
26
|
+
}));
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
me: meUser,
|
|
30
|
+
org: orgDto,
|
|
31
|
+
members: memberList,
|
|
32
|
+
invites: [] as OrgInviteDto[],
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export function OrgDetailScreen({
|
|
37
|
+
organizationId,
|
|
38
|
+
onBack,
|
|
39
|
+
}: {
|
|
40
|
+
organizationId: string;
|
|
41
|
+
onBack: () => void;
|
|
42
|
+
}) {
|
|
43
|
+
const [me, setMe] = useState<UserDto | null>(null);
|
|
44
|
+
const [org, setOrg] = useState<OrganizationDto | null>(null);
|
|
45
|
+
const [members, setMembers] = useState<MembershipDto[]>([]);
|
|
46
|
+
const [invites, setInvites] = useState<OrgInviteDto[]>([]);
|
|
47
|
+
const [email, setEmail] = useState('');
|
|
48
|
+
const [error, setError] = useState<string | null>(null);
|
|
49
|
+
|
|
50
|
+
const myRole = members.find((row) => row.userId === me?.id)?.role;
|
|
51
|
+
const canInvite = myRole ? orgRoleCan(myRole, 'org.invite') : false;
|
|
52
|
+
const canRemove = myRole ? orgRoleCan(myRole, 'org.member.remove') : false;
|
|
53
|
+
|
|
54
|
+
const apply = (row: {
|
|
55
|
+
me: UserDto;
|
|
56
|
+
org: OrganizationDto;
|
|
57
|
+
members: MembershipDto[];
|
|
58
|
+
invites: OrgInviteDto[];
|
|
59
|
+
}) => {
|
|
60
|
+
setMe(row.me);
|
|
61
|
+
setOrg(row.org);
|
|
62
|
+
setMembers(row.members);
|
|
63
|
+
setInvites(row.invites);
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
fetchOrgDetail(organizationId)
|
|
68
|
+
.then((row) => {
|
|
69
|
+
setMe(row.me);
|
|
70
|
+
setOrg(row.org);
|
|
71
|
+
setMembers(row.members);
|
|
72
|
+
setInvites(row.invites);
|
|
73
|
+
})
|
|
74
|
+
.catch((err: Error) => setError(err.message));
|
|
75
|
+
}, [organizationId]);
|
|
76
|
+
|
|
77
|
+
const reload = () => {
|
|
78
|
+
fetchOrgDetail(organizationId)
|
|
79
|
+
.then(apply)
|
|
80
|
+
.catch((err: Error) => setError(err.message));
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const invite = () => {
|
|
84
|
+
const parsed = InviteMemberCommandSchema.safeParse({ email, role: 'MEMBER' });
|
|
85
|
+
if (!parsed.success) {
|
|
86
|
+
setError(parsed.error.issues[0]?.message ?? 'Invalid');
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
setError(null);
|
|
90
|
+
api.organizations
|
|
91
|
+
.invite(organizationId, parsed.data)
|
|
92
|
+
.then(() => {
|
|
93
|
+
setEmail('');
|
|
94
|
+
reload();
|
|
95
|
+
})
|
|
96
|
+
.catch((err: Error) => setError(err.message));
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
return (
|
|
100
|
+
<View>
|
|
101
|
+
<Button title="Back" onPress={onBack} />
|
|
102
|
+
<Text>{org?.name ?? 'Organization'}</Text>
|
|
103
|
+
{error ? <Text>{error}</Text> : null}
|
|
104
|
+
{canInvite ? (
|
|
105
|
+
<View>
|
|
106
|
+
<TextInput
|
|
107
|
+
autoCapitalize="none"
|
|
108
|
+
keyboardType="email-address"
|
|
109
|
+
onChangeText={setEmail}
|
|
110
|
+
value={email}
|
|
111
|
+
/>
|
|
112
|
+
<Button title="Invite" onPress={invite} />
|
|
113
|
+
</View>
|
|
114
|
+
) : null}
|
|
115
|
+
{members.map((member) => (
|
|
116
|
+
<View key={member.id}>
|
|
117
|
+
<Text>
|
|
118
|
+
{member.displayName} · {member.email ?? '—'} · {member.role}
|
|
119
|
+
</Text>
|
|
120
|
+
{canRemove && member.userId !== me?.id ? (
|
|
121
|
+
<Button
|
|
122
|
+
title="Remove"
|
|
123
|
+
onPress={() => {
|
|
124
|
+
api.organizations
|
|
125
|
+
.removeMember(organizationId, member.userId)
|
|
126
|
+
.then(() => reload())
|
|
127
|
+
.catch((err: Error) => setError(err.message));
|
|
128
|
+
}}
|
|
129
|
+
/>
|
|
130
|
+
) : null}
|
|
131
|
+
</View>
|
|
132
|
+
))}
|
|
133
|
+
{canInvite && invites.length > 0 ? (
|
|
134
|
+
<View>
|
|
135
|
+
<Text>Pending invites</Text>
|
|
136
|
+
{invites.map((row) => (
|
|
137
|
+
<Text key={row.id}>
|
|
138
|
+
{row.email} · {row.role}
|
|
139
|
+
</Text>
|
|
140
|
+
))}
|
|
141
|
+
</View>
|
|
142
|
+
) : null}
|
|
143
|
+
<Button
|
|
144
|
+
title="Leave"
|
|
145
|
+
onPress={() => {
|
|
146
|
+
api.organizations
|
|
147
|
+
.leave(organizationId)
|
|
148
|
+
.then(() => onBack())
|
|
149
|
+
.catch((err: Error) => setError(err.message));
|
|
150
|
+
}}
|
|
151
|
+
/>
|
|
152
|
+
</View>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { CreateOrganizationCommandSchema, type OrganizationDto } from '@ysk-kit/contracts';
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { Button, Text, TextInput, View } from 'react-native';
|
|
4
|
+
import { api } from '../lib/client';
|
|
5
|
+
|
|
6
|
+
export function OrgsScreen({
|
|
7
|
+
onBack,
|
|
8
|
+
onOpen,
|
|
9
|
+
}: {
|
|
10
|
+
onBack: () => void;
|
|
11
|
+
onOpen: (organizationId: string) => void;
|
|
12
|
+
}) {
|
|
13
|
+
const [orgs, setOrgs] = useState<OrganizationDto[]>([]);
|
|
14
|
+
const [name, setName] = useState('');
|
|
15
|
+
const [error, setError] = useState<string | null>(null);
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
api.organizations
|
|
19
|
+
.list()
|
|
20
|
+
.then(setOrgs)
|
|
21
|
+
.catch((err: Error) => setError(err.message));
|
|
22
|
+
}, []);
|
|
23
|
+
|
|
24
|
+
const submit = () => {
|
|
25
|
+
const parsed = CreateOrganizationCommandSchema.safeParse({ name });
|
|
26
|
+
if (!parsed.success) {
|
|
27
|
+
setError(parsed.error.issues[0]?.message ?? 'Invalid');
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
setError(null);
|
|
31
|
+
api.organizations
|
|
32
|
+
.create(parsed.data)
|
|
33
|
+
.then(() => {
|
|
34
|
+
setName('');
|
|
35
|
+
return api.organizations.list();
|
|
36
|
+
})
|
|
37
|
+
.then(setOrgs)
|
|
38
|
+
.catch((err: Error) => setError(err.message));
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<View>
|
|
43
|
+
<Button title="Back" onPress={onBack} />
|
|
44
|
+
<Text>Organizations</Text>
|
|
45
|
+
<TextInput onChangeText={setName} value={name} />
|
|
46
|
+
<Button title="Create" onPress={submit} />
|
|
47
|
+
{error ? <Text>{error}</Text> : null}
|
|
48
|
+
{orgs.map((org) => (
|
|
49
|
+
<Button key={org.id} title={org.name} onPress={() => onOpen(org.id)} />
|
|
50
|
+
))}
|
|
51
|
+
</View>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { useNavigate } from '@tanstack/react-router';
|
|
2
|
+
import { AcceptInviteCommandSchema } from '@ysk-kit/contracts';
|
|
3
|
+
import { Button, Input } from '@ysk-kit/ui';
|
|
4
|
+
import { createOrganizationHooks } from '@ysk-kit/web-sdk';
|
|
5
|
+
import { type FormEvent, useEffect, useState } from 'react';
|
|
6
|
+
import { api } from '../../lib/client';
|
|
7
|
+
|
|
8
|
+
const orgHooks = createOrganizationHooks(api);
|
|
9
|
+
|
|
10
|
+
export function InvitePage() {
|
|
11
|
+
const accept = orgHooks.useAcceptInvite();
|
|
12
|
+
const navigate = useNavigate();
|
|
13
|
+
const [token, setToken] = useState('');
|
|
14
|
+
const [password, setPassword] = useState('');
|
|
15
|
+
const [displayName, setDisplayName] = useState('');
|
|
16
|
+
const [formError, setFormError] = useState<string | null>(null);
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
const params = new URLSearchParams(window.location.search);
|
|
20
|
+
const fromQuery = params.get('token');
|
|
21
|
+
if (fromQuery) {
|
|
22
|
+
setToken(fromQuery);
|
|
23
|
+
params.delete('token');
|
|
24
|
+
const next = `${window.location.pathname}${params.toString() ? `?${params}` : ''}`;
|
|
25
|
+
window.history.replaceState({}, '', next);
|
|
26
|
+
}
|
|
27
|
+
}, []);
|
|
28
|
+
|
|
29
|
+
const onSubmit = (event: FormEvent) => {
|
|
30
|
+
event.preventDefault();
|
|
31
|
+
const parsed = AcceptInviteCommandSchema.safeParse({
|
|
32
|
+
token,
|
|
33
|
+
...(password.length > 0 ? { password } : {}),
|
|
34
|
+
...(displayName.length > 0 ? { displayName } : {}),
|
|
35
|
+
});
|
|
36
|
+
if (!parsed.success) {
|
|
37
|
+
setFormError(parsed.error.issues[0]?.message ?? 'Invalid');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
setFormError(null);
|
|
41
|
+
accept.mutate(parsed.data, {
|
|
42
|
+
onSuccess: () => {
|
|
43
|
+
void navigate({ to: '/login' });
|
|
44
|
+
},
|
|
45
|
+
onError: (error) => setFormError(error.message),
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<section className="mx-auto max-w-sm space-y-4">
|
|
51
|
+
<h1 className="text-2xl font-semibold">Accept invite</h1>
|
|
52
|
+
<form onSubmit={onSubmit} className="grid gap-3">
|
|
53
|
+
<label className="grid gap-1 text-sm" htmlFor="invite-password">
|
|
54
|
+
Password (required for new accounts)
|
|
55
|
+
<Input
|
|
56
|
+
id="invite-password"
|
|
57
|
+
type="password"
|
|
58
|
+
value={password}
|
|
59
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
60
|
+
/>
|
|
61
|
+
</label>
|
|
62
|
+
<label className="grid gap-1 text-sm" htmlFor="invite-name">
|
|
63
|
+
Display name
|
|
64
|
+
<Input
|
|
65
|
+
id="invite-name"
|
|
66
|
+
value={displayName}
|
|
67
|
+
onChange={(e) => setDisplayName(e.target.value)}
|
|
68
|
+
/>
|
|
69
|
+
</label>
|
|
70
|
+
{formError ? <p className="text-sm text-red-600">{formError}</p> : null}
|
|
71
|
+
<Button type="submit" disabled={accept.isPending || token.length === 0}>
|
|
72
|
+
Accept
|
|
73
|
+
</Button>
|
|
74
|
+
</form>
|
|
75
|
+
</section>
|
|
76
|
+
);
|
|
77
|
+
}
|