Skip to content

Commit

Permalink
feat(client/ts): Add support for changing user name, user first and l…
Browse files Browse the repository at this point in the history
…ast name, retrieving users by keys, and more to TypeScript client
  • Loading branch information
pjdotson committed Sep 13, 2024
1 parent 8cf3c92 commit 9faf6a5
Show file tree
Hide file tree
Showing 8 changed files with 556 additions and 41 deletions.
4 changes: 2 additions & 2 deletions client/py/synnax/user/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from synnax.auth import InsecureCredentials, TokenResponse
from synnax.user.payload import UserPayload

_REGISTER_ENDPOINT = "/user/register"
_CREATE_ENDPOINT = "/user/create"


class UserClient:
Expand All @@ -26,7 +26,7 @@ def __init__(
def register(self, username: str, password: str) -> UserPayload:
return send_required(
self.client,
_REGISTER_ENDPOINT,
_CREATE_ENDPOINT,
InsecureCredentials(username=username, password=password),
TokenResponse,
).user
14 changes: 7 additions & 7 deletions client/ts/src/access/access.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,7 @@ import { SchematicOntologyType } from "@/workspace/schematic/payload";

const client = newClient();

const sortByKey = (a: any, b: any) => {
return a.key.localeCompare(b.key);
};
const sortByKey = (a: any, b: any) => a.key.localeCompare(b.key);

describe("Policy", () => {
describe("create", () => {
Expand Down Expand Up @@ -274,7 +272,7 @@ describe("Policy", () => {
describe("Registration", async () => {
test("register a user", async () => {
const username = id.id();
await client.user.register(username, "pwd1");
await client.user.create({ username, password: "pwd1" });
new Synnax({
host: HOST,
port: PORT,
Expand All @@ -284,14 +282,16 @@ describe("Registration", async () => {
});
test("duplicate username", async () => {
const username = id.id();
await client.user.register(username, "pwd1");
await expect(client.user.register(username, "pwd1")).rejects.toThrow(AuthError);
await client.user.create({ username, password: "pwd1" });
await expect(client.user.create({ username, password: "pwd1" })).rejects.toThrow(
AuthError,
);
});
});
describe("privilege", async () => {
test("new user", async () => {
const username = id.id();
const user2 = await client.user.register(username, "pwd1");
const user2 = await client.user.create({ username, password: "pwd1" });
expect(user2).toBeDefined();
const client2 = new Synnax({
host: HOST,
Expand Down
64 changes: 56 additions & 8 deletions client/ts/src/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,36 +7,51 @@
// License, use of this software will be governed by the Apache License, Version 2.0,
// included in the file licenses/APL.txt.

import { type Middleware, type UnaryClient } from "@synnaxlabs/freighter";
import { type Middleware, sendRequired, type UnaryClient } from "@synnaxlabs/freighter";
import { z } from "zod";

import { InvalidTokenError } from "@/errors";
import { user } from "@/user";

export const insecureCredentialsZ = z.object({
const insecureCredentialsZ = z.object({
username: z.string(),
password: z.string(),
});
export type InsecureCredentials = z.infer<typeof insecureCredentialsZ>;
type InsecureCredentials = z.infer<typeof insecureCredentialsZ>;

export const tokenResponseZ = z.object({
const tokenResponseZ = z.object({
token: z.string(),
user: user.payloadZ,
user: user.userZ,
});

export type TokenResponse = z.infer<typeof tokenResponseZ>;

const LOGIN_ENDPOINT = "/auth/login";

const MAX_RETRIES = 3;

const CHANGE_USERNAME_ENDPOINT = "/auth/change-username";
const CHANGE_PASSWORD_ENDPOINT = "/auth/change-password";

const changeUsernameReqZ = z.object({
username: z.string(),
password: z.string(),
newUsername: z.string().min(1),
});
const changeUsernameResZ = z.object({});

const changePasswordReqZ = z.object({
username: z.string(),
password: z.string(),
newPassword: z.string().min(1),
});
const changePasswordResZ = z.object({});

export class Client {
token: string | undefined;
private readonly client: UnaryClient;
private readonly credentials: InsecureCredentials;
private authenticating: Promise<Error | null> | undefined;
authenticated: boolean;
user: user.Payload | undefined;
user: user.User | undefined;
private retryCount: number;

constructor(client: UnaryClient, credentials: InsecureCredentials) {
Expand All @@ -46,6 +61,39 @@ export class Client {
this.retryCount = 0;
}

async changeUsername(newUsername: string): Promise<void> {
if (!this.authenticated || this.user == null) throw new Error("Not authenticated");
await sendRequired<typeof changeUsernameReqZ, typeof changeUsernameResZ>(
this.client,
CHANGE_USERNAME_ENDPOINT,
{
username: this.credentials.username,
password: this.credentials.password,
newUsername,
},
changeUsernameReqZ,
changeUsernameResZ,
);
this.credentials.username = newUsername;
this.user.username = newUsername;
}

async changePassword(newPassword: string): Promise<void> {
if (!this.authenticated) throw new Error("Not authenticated");
await sendRequired<typeof changePasswordReqZ, typeof changePasswordResZ>(
this.client,
CHANGE_PASSWORD_ENDPOINT,
{
username: this.credentials.username,
password: this.credentials.password,
newPassword,
},
changePasswordReqZ,
changePasswordResZ,
);
this.credentials.password = newPassword;
}

middleware(): Middleware {
const mw: Middleware = async (reqCtx, next) => {
if (!this.authenticated && !reqCtx.target.endsWith(LOGIN_ENDPOINT)) {
Expand Down
82 changes: 63 additions & 19 deletions client/ts/src/user/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,31 +7,75 @@
// License, use of this software will be governed by the Apache License, Version 2.0,
// included in the file licenses/APL.txt.

import { sendRequired, type UnaryClient } from "@synnaxlabs/freighter";
import { type UnaryClient } from "@synnaxlabs/freighter";
import { toArray } from "@synnaxlabs/x";

import { insecureCredentialsZ, tokenResponseZ } from "@/auth/auth";
import { Payload } from "@/user/payload";

const REGISTER_ENDPOINT = "/user/register";
import { MultipleFoundError, NotFoundError } from "@/errors";
import { type Key, type NewUser, type User } from "@/user/payload";
import { Retriever } from "@/user/retriever";
import { Writer } from "@/user/writer";

export class Client {
private readonly client: UnaryClient;
private readonly reader: Retriever;
private readonly writer: Writer;

constructor(client: UnaryClient) {
this.client = client;
this.writer = new Writer(client);
this.reader = new Retriever(client);
}

async create(user: NewUser): Promise<User>;

async create(users: NewUser[]): Promise<User[]>;

async create(users: NewUser | NewUser[]): Promise<User | User[]> {
const isMany = Array.isArray(users);
const res = await this.writer.create(users);
return isMany ? res : res[0];
}

async register(username: string, password: string): Promise<Payload> {
const { user } = await sendRequired<
typeof insecureCredentialsZ,
typeof tokenResponseZ
>(
this.client,
REGISTER_ENDPOINT,
{ username: username, password: password },
insecureCredentialsZ,
tokenResponseZ,
);
return user;
async changeUsername(key: Key, newUsername: string): Promise<void> {
await this.writer.changeUsername(key, newUsername);
}

async retrieve(key: Key): Promise<User>;

async retrieve(keys: Key[]): Promise<User[]>;

async retrieve(keys: Key | Key[]): Promise<User | User[]> {
const isMany = Array.isArray(keys);
const res = await this.reader.retrieve({ keys: toArray(keys) });
if (isMany) return res;
if (res.length === 0) throw new NotFoundError(`No user with key ${keys} found`);
if (res.length > 1)
throw new MultipleFoundError(`Multiple users found with key ${keys}`);
return res[0];
}

async retrieveByName(username: string): Promise<User>;

async retrieveByName(usernames: string[]): Promise<User[]>;

async retrieveByName(usernames: string | string[]): Promise<User | User[]> {
const isMany = Array.isArray(usernames);
const res = await this.reader.retrieve({ usernames: toArray(usernames) });
if (isMany) return res;
if (res.length === 0)
throw new NotFoundError(`No user with username ${usernames} found`);
if (res.length > 1)
throw new MultipleFoundError(`Multiple users found with username ${usernames}`);
return res[0];
}

async changeName(key: Key, firstName?: string, lastName?: string): Promise<void> {
await this.writer.changeName(key, firstName, lastName);
}

async delete(key: Key): Promise<void>;

async delete(keys: Key[]): Promise<void>;

async delete(keys: Key | Key[]): Promise<void> {
await this.writer.delete(toArray(keys));
}
}
15 changes: 10 additions & 5 deletions client/ts/src/user/payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,20 @@ import { z } from "zod";
import { ontology } from "@/ontology";

export const keyZ = z.string().uuid();

export type Key = z.infer<typeof keyZ>;

export const payloadZ = z.object({
key: z.string(),
username: z.string(),
export const userZ = z.object({
key: keyZ,
username: z.string().min(1),
firstName: z.string().optional(),
lastName: z.string().optional(),
});
export type User = z.infer<typeof userZ>;

export type Payload = z.infer<typeof payloadZ>;
export const newUserZ = userZ
.omit({ key: true })
.extend({ password: z.string().min(1) });
export type NewUser = z.infer<typeof newUserZ>;

export const UserOntologyType = "user" as ontology.ResourceType;

Expand Down
41 changes: 41 additions & 0 deletions client/ts/src/user/retriever.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Copyright 2024 Synnax Labs, Inc.
//
// Use of this software is governed by the Business Source License included in the file
// licenses/BSL.txt.
//
// As of the Change Date specified in that file, in accordance with the Business Source
// License, use of this software will be governed by the Apache License, Version 2.0,
// included in the file licenses/APL.txt.

import { sendRequired, type UnaryClient } from "@synnaxlabs/freighter";
import { z } from "zod";

import { keyZ, type User, userZ } from "@/user/payload";
import { nullableArrayZ } from "@/util/zod";

const reqZ = z.object({
keys: keyZ.array().optional(),
usernames: z.string().array().optional(),
});
type Request = z.infer<typeof reqZ>;
const resZ = z.object({ users: nullableArrayZ(userZ) });
const RETRIEVE_ENDPOINT = "/user/retrieve";

export class Retriever {
private readonly client: UnaryClient;

constructor(client: UnaryClient) {
this.client = client;
}

async retrieve(req: Request): Promise<User[]> {
const res = await sendRequired<typeof reqZ, typeof resZ>(
this.client,
RETRIEVE_ENDPOINT,
req,
reqZ,
resZ,
);
return res.users;
}
}
Loading

0 comments on commit 9faf6a5

Please sign in to comment.