mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
refactor: update invitation
This commit is contained in:
@@ -1,6 +1,3 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { findAdminByAuthId } from "@dokploy/server/services/admin";
|
||||
import { findUserByAuthId } from "@dokploy/server/services/user";
|
||||
import { DrizzlePostgreSQLAdapter } from "@lucia-auth/adapter-drizzle";
|
||||
import { TimeSpan } from "lucia";
|
||||
import { Lucia } from "lucia/dist/core.js";
|
||||
@@ -42,78 +39,3 @@ export type ReturnValidateToken = Promise<{
|
||||
user: (User & { authId: string; adminId: string }) | null;
|
||||
session: Session | null;
|
||||
}>;
|
||||
|
||||
export async function validateRequest(
|
||||
req: IncomingMessage,
|
||||
res: ServerResponse,
|
||||
): ReturnValidateToken {
|
||||
console.log(session);
|
||||
const sessionId = lucia.readSessionCookie(req.headers.cookie ?? "");
|
||||
|
||||
if (!sessionId) {
|
||||
return {
|
||||
user: null,
|
||||
session: null,
|
||||
};
|
||||
}
|
||||
const result = await lucia.validateSession(sessionId);
|
||||
if (result?.session?.fresh) {
|
||||
res.appendHeader(
|
||||
"Set-Cookie",
|
||||
lucia.createSessionCookie(result.session.id).serialize(),
|
||||
);
|
||||
}
|
||||
if (!result.session) {
|
||||
res.appendHeader(
|
||||
"Set-Cookie",
|
||||
lucia.createBlankSessionCookie().serialize(),
|
||||
);
|
||||
}
|
||||
if (result.user) {
|
||||
try {
|
||||
if (result.user?.rol === "owner") {
|
||||
const admin = await findAdminByAuthId(result.user.id);
|
||||
result.user.adminId = admin.adminId;
|
||||
} else if (result.user?.rol === "member") {
|
||||
const userResult = await findUserByAuthId(result.user.id);
|
||||
result.user.adminId = userResult.adminId;
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
user: null,
|
||||
session: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
session: result.session,
|
||||
...((result.user && {
|
||||
user: {
|
||||
authId: result.user.id,
|
||||
email: result.user.email,
|
||||
rol: result.user.rol,
|
||||
id: result.user.id,
|
||||
secret: result.user.secret,
|
||||
adminId: result.user.adminId,
|
||||
},
|
||||
}) || {
|
||||
user: null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function validateWebSocketRequest(
|
||||
req: IncomingMessage,
|
||||
): Promise<{ user: User; session: Session } | { user: null; session: null }> {
|
||||
const sessionId = lucia.readSessionCookie(req.headers.cookie ?? "");
|
||||
|
||||
if (!sessionId) {
|
||||
return {
|
||||
user: null,
|
||||
session: null,
|
||||
};
|
||||
}
|
||||
const result = await lucia.validateSession(sessionId);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -21,79 +21,79 @@ export const luciaToken = new Lucia(adapter, {
|
||||
},
|
||||
});
|
||||
|
||||
export const validateBearerToken = async (
|
||||
req: IncomingMessage,
|
||||
): ReturnValidateToken => {
|
||||
const authorizationHeader = req.headers.authorization;
|
||||
const sessionId = luciaToken.readBearerToken(authorizationHeader ?? "");
|
||||
if (!sessionId) {
|
||||
return {
|
||||
user: null,
|
||||
session: null,
|
||||
};
|
||||
}
|
||||
const result = await luciaToken.validateSession(sessionId);
|
||||
// export const validateBearerToken = async (
|
||||
// req: IncomingMessage,
|
||||
// ): ReturnValidateToken => {
|
||||
// const authorizationHeader = req.headers.authorization;
|
||||
// const sessionId = luciaToken.readBearerToken(authorizationHeader ?? "");
|
||||
// if (!sessionId) {
|
||||
// return {
|
||||
// user: null,
|
||||
// session: null,
|
||||
// };
|
||||
// }
|
||||
// const result = await luciaToken.validateSession(sessionId);
|
||||
|
||||
if (result.user) {
|
||||
if (result.user?.rol === "owner") {
|
||||
const admin = await findAdminByAuthId(result.user.id);
|
||||
result.user.adminId = admin.adminId;
|
||||
} else if (result.user?.rol === "member") {
|
||||
const userResult = await findUserByAuthId(result.user.id);
|
||||
result.user.adminId = userResult.adminId;
|
||||
}
|
||||
}
|
||||
return {
|
||||
session: result.session,
|
||||
...((result.user && {
|
||||
user: {
|
||||
adminId: result.user.adminId,
|
||||
authId: result.user.id,
|
||||
email: result.user.email,
|
||||
rol: result.user.rol,
|
||||
id: result.user.id,
|
||||
secret: result.user.secret,
|
||||
},
|
||||
}) || {
|
||||
user: null,
|
||||
}),
|
||||
};
|
||||
};
|
||||
// if (result.user) {
|
||||
// if (result.user?.rol === "owner") {
|
||||
// const admin = await findAdminByAuthId(result.user.id);
|
||||
// result.user.adminId = admin.adminId;
|
||||
// } else if (result.user?.rol === "member") {
|
||||
// const userResult = await findUserByAuthId(result.user.id);
|
||||
// result.user.adminId = userResult.adminId;
|
||||
// }
|
||||
// }
|
||||
// return {
|
||||
// session: result.session,
|
||||
// ...((result.user && {
|
||||
// user: {
|
||||
// adminId: result.user.adminId,
|
||||
// authId: result.user.id,
|
||||
// email: result.user.email,
|
||||
// rol: result.user.rol,
|
||||
// id: result.user.id,
|
||||
// secret: result.user.secret,
|
||||
// },
|
||||
// }) || {
|
||||
// user: null,
|
||||
// }),
|
||||
// };
|
||||
// };
|
||||
|
||||
export const validateBearerTokenAPI = async (
|
||||
authorizationHeader: string,
|
||||
): ReturnValidateToken => {
|
||||
const sessionId = luciaToken.readBearerToken(authorizationHeader ?? "");
|
||||
if (!sessionId) {
|
||||
return {
|
||||
user: null,
|
||||
session: null,
|
||||
};
|
||||
}
|
||||
const result = await luciaToken.validateSession(sessionId);
|
||||
// export const validateBearerTokenAPI = async (
|
||||
// authorizationHeader: string,
|
||||
// ): ReturnValidateToken => {
|
||||
// const sessionId = luciaToken.readBearerToken(authorizationHeader ?? "");
|
||||
// if (!sessionId) {
|
||||
// return {
|
||||
// user: null,
|
||||
// session: null,
|
||||
// };
|
||||
// }
|
||||
// const result = await luciaToken.validateSession(sessionId);
|
||||
|
||||
if (result.user) {
|
||||
if (result.user?.rol === "owner") {
|
||||
const admin = await findAdminByAuthId(result.user.id);
|
||||
result.user.adminId = admin.adminId;
|
||||
} else if (result.user?.rol === "member") {
|
||||
const userResult = await findUserByAuthId(result.user.id);
|
||||
result.user.adminId = userResult.adminId;
|
||||
}
|
||||
}
|
||||
return {
|
||||
session: result.session,
|
||||
...((result.user && {
|
||||
user: {
|
||||
adminId: result.user.adminId,
|
||||
authId: result.user.id,
|
||||
email: result.user.email,
|
||||
rol: result.user.rol,
|
||||
id: result.user.id,
|
||||
secret: result.user.secret,
|
||||
},
|
||||
}) || {
|
||||
user: null,
|
||||
}),
|
||||
};
|
||||
};
|
||||
// if (result.user) {
|
||||
// if (result.user?.rol === "owner") {
|
||||
// const admin = await findAdminByAuthId(result.user.id);
|
||||
// result.user.adminId = admin.adminId;
|
||||
// } else if (result.user?.rol === "member") {
|
||||
// const userResult = await findUserByAuthId(result.user.id);
|
||||
// result.user.adminId = userResult.adminId;
|
||||
// }
|
||||
// }
|
||||
// return {
|
||||
// session: result.session,
|
||||
// ...((result.user && {
|
||||
// user: {
|
||||
// adminId: result.user.adminId,
|
||||
// authId: result.user.id,
|
||||
// email: result.user.email,
|
||||
// rol: result.user.rol,
|
||||
// id: result.user.id,
|
||||
// secret: result.user.secret,
|
||||
// },
|
||||
// }) || {
|
||||
// user: null,
|
||||
// }),
|
||||
// };
|
||||
// };
|
||||
|
||||
@@ -84,7 +84,22 @@ export const auth = betterAuth({
|
||||
},
|
||||
},
|
||||
|
||||
plugins: [organization()],
|
||||
plugins: [
|
||||
organization({
|
||||
async sendInvitationEmail(data, request) {
|
||||
const inviteLink = `https://example.com/accept-invitation/${data.id}`;
|
||||
// https://example.com/accept-invitation/8jlBi9Tb9isDb8mc8Sb85u1BaJYklKB2
|
||||
// sendOrganizationInvitation({
|
||||
// email: data.email,
|
||||
// invitedByUsername: data.inviter.user.name,
|
||||
// invitedByEmail: data.inviter.user.email,
|
||||
// teamName: data.organization.name,
|
||||
// inviteLink
|
||||
// })
|
||||
console.log("Invitation link", inviteLink);
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
export const validateRequest = async (request: IncomingMessage) => {
|
||||
|
||||
@@ -185,11 +185,11 @@ export const deployApplication = async ({
|
||||
});
|
||||
|
||||
try {
|
||||
const admin = await findUserById(application.project.userId);
|
||||
// const admin = await findUserById(application.project.userId);
|
||||
|
||||
if (admin.cleanupCacheApplications) {
|
||||
await cleanupFullDocker(application?.serverId);
|
||||
}
|
||||
// if (admin.cleanupCacheApplications) {
|
||||
// await cleanupFullDocker(application?.serverId);
|
||||
// }
|
||||
|
||||
if (application.sourceType === "github") {
|
||||
await cloneGithubRepository({
|
||||
@@ -220,7 +220,7 @@ export const deployApplication = async ({
|
||||
applicationName: application.name,
|
||||
applicationType: "application",
|
||||
buildLink,
|
||||
userId: application.project.userId,
|
||||
organizationId: application.project.organizationId,
|
||||
domains: application.domains,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -233,7 +233,7 @@ export const deployApplication = async ({
|
||||
// @ts-ignore
|
||||
errorMessage: error?.message || "Error building",
|
||||
buildLink,
|
||||
userId: application.project.userId,
|
||||
organizationId: application.project.organizationId,
|
||||
});
|
||||
|
||||
throw error;
|
||||
@@ -260,11 +260,11 @@ export const rebuildApplication = async ({
|
||||
});
|
||||
|
||||
try {
|
||||
const admin = await findUserById(application.project.userId);
|
||||
// const admin = await findUserById(application.project.userId);
|
||||
|
||||
if (admin.cleanupCacheApplications) {
|
||||
await cleanupFullDocker(application?.serverId);
|
||||
}
|
||||
// if (admin.cleanupCacheApplications) {
|
||||
// await cleanupFullDocker(application?.serverId);
|
||||
// }
|
||||
if (application.sourceType === "github") {
|
||||
await buildApplication(application, deployment.logPath);
|
||||
} else if (application.sourceType === "gitlab") {
|
||||
@@ -309,11 +309,11 @@ export const deployRemoteApplication = async ({
|
||||
|
||||
try {
|
||||
if (application.serverId) {
|
||||
const admin = await findUserById(application.project.userId);
|
||||
// const admin = await findUserById(application.project.userId);
|
||||
|
||||
if (admin.cleanupCacheApplications) {
|
||||
await cleanupFullDocker(application?.serverId);
|
||||
}
|
||||
// if (admin.cleanupCacheApplications) {
|
||||
// await cleanupFullDocker(application?.serverId);
|
||||
// }
|
||||
let command = "set -e;";
|
||||
if (application.sourceType === "github") {
|
||||
command += await getGithubCloneCommand({
|
||||
@@ -352,7 +352,7 @@ export const deployRemoteApplication = async ({
|
||||
applicationName: application.name,
|
||||
applicationType: "application",
|
||||
buildLink,
|
||||
userId: application.project.userId,
|
||||
organizationId: application.project.organizationId,
|
||||
domains: application.domains,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -376,7 +376,7 @@ export const deployRemoteApplication = async ({
|
||||
// @ts-ignore
|
||||
errorMessage: error?.message || "Error building",
|
||||
buildLink,
|
||||
userId: application.project.userId,
|
||||
organizationId: application.project.organizationId,
|
||||
});
|
||||
|
||||
throw error;
|
||||
@@ -454,11 +454,11 @@ export const deployPreviewApplication = async ({
|
||||
application.env = `${application.previewEnv}\nDOKPLOY_DEPLOY_URL=${previewDeployment?.domain}`;
|
||||
application.buildArgs = application.previewBuildArgs;
|
||||
|
||||
const admin = await findUserById(application.project.userId);
|
||||
// const admin = await findUserById(application.project.userId);
|
||||
|
||||
if (admin.cleanupCacheOnPreviews) {
|
||||
await cleanupFullDocker(application?.serverId);
|
||||
}
|
||||
// if (admin.cleanupCacheOnPreviews) {
|
||||
// await cleanupFullDocker(application?.serverId);
|
||||
// }
|
||||
|
||||
if (application.sourceType === "github") {
|
||||
await cloneGithubRepository({
|
||||
@@ -568,11 +568,11 @@ export const deployRemotePreviewApplication = async ({
|
||||
application.buildArgs = application.previewBuildArgs;
|
||||
|
||||
if (application.serverId) {
|
||||
const admin = await findUserById(application.project.userId);
|
||||
// const admin = await findUserById(application.project.userId);
|
||||
|
||||
if (admin.cleanupCacheOnPreviews) {
|
||||
await cleanupFullDocker(application?.serverId);
|
||||
}
|
||||
// if (admin.cleanupCacheOnPreviews) {
|
||||
// await cleanupFullDocker(application?.serverId);
|
||||
// }
|
||||
let command = "set -e;";
|
||||
if (application.sourceType === "github") {
|
||||
command += await getGithubCloneCommand({
|
||||
@@ -637,11 +637,11 @@ export const rebuildRemoteApplication = async ({
|
||||
|
||||
try {
|
||||
if (application.serverId) {
|
||||
const admin = await findUserById(application.project.userId);
|
||||
// const admin = await findUserById(application.project.userId);
|
||||
|
||||
if (admin.cleanupCacheApplications) {
|
||||
await cleanupFullDocker(application?.serverId);
|
||||
}
|
||||
// if (admin.cleanupCacheApplications) {
|
||||
// await cleanupFullDocker(application?.serverId);
|
||||
// }
|
||||
if (application.sourceType !== "docker") {
|
||||
let command = "set -e;";
|
||||
command += getBuildCommand(application, deployment.logPath);
|
||||
|
||||
@@ -217,10 +217,10 @@ export const deployCompose = async ({
|
||||
});
|
||||
|
||||
try {
|
||||
const admin = await findUserById(compose.project.userId);
|
||||
if (admin.cleanupCacheOnCompose) {
|
||||
await cleanupFullDocker(compose?.serverId);
|
||||
}
|
||||
// const admin = await findUserById(compose.project.userId);
|
||||
// if (admin.cleanupCacheOnCompose) {
|
||||
// await cleanupFullDocker(compose?.serverId);
|
||||
// }
|
||||
if (compose.sourceType === "github") {
|
||||
await cloneGithubRepository({
|
||||
...compose,
|
||||
@@ -247,7 +247,7 @@ export const deployCompose = async ({
|
||||
applicationName: compose.name,
|
||||
applicationType: "compose",
|
||||
buildLink,
|
||||
userId: compose.project.userId,
|
||||
organizationId: compose.project.organizationId,
|
||||
domains: compose.domains,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -262,7 +262,7 @@ export const deployCompose = async ({
|
||||
// @ts-ignore
|
||||
errorMessage: error?.message || "Error building",
|
||||
buildLink,
|
||||
userId: compose.project.userId,
|
||||
organizationId: compose.project.organizationId,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
@@ -286,10 +286,10 @@ export const rebuildCompose = async ({
|
||||
});
|
||||
|
||||
try {
|
||||
const admin = await findUserById(compose.project.userId);
|
||||
if (admin.cleanupCacheOnCompose) {
|
||||
await cleanupFullDocker(compose?.serverId);
|
||||
}
|
||||
// const admin = await findUserById(compose.project.userId);
|
||||
// if (admin.cleanupCacheOnCompose) {
|
||||
// await cleanupFullDocker(compose?.serverId);
|
||||
// }
|
||||
if (compose.serverId) {
|
||||
await getBuildComposeCommand(compose, deployment.logPath);
|
||||
} else {
|
||||
@@ -332,10 +332,10 @@ export const deployRemoteCompose = async ({
|
||||
});
|
||||
try {
|
||||
if (compose.serverId) {
|
||||
const admin = await findUserById(compose.project.userId);
|
||||
if (admin.cleanupCacheOnCompose) {
|
||||
await cleanupFullDocker(compose?.serverId);
|
||||
}
|
||||
// const admin = await findUserById(compose.project.userId);
|
||||
// if (admin.cleanupCacheOnCompose) {
|
||||
// await cleanupFullDocker(compose?.serverId);
|
||||
// }
|
||||
let command = "set -e;";
|
||||
|
||||
if (compose.sourceType === "github") {
|
||||
@@ -381,7 +381,7 @@ export const deployRemoteCompose = async ({
|
||||
applicationName: compose.name,
|
||||
applicationType: "compose",
|
||||
buildLink,
|
||||
userId: compose.project.userId,
|
||||
organizationId: compose.project.organizationId,
|
||||
domains: compose.domains,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -406,7 +406,7 @@ export const deployRemoteCompose = async ({
|
||||
// @ts-ignore
|
||||
errorMessage: error?.message || "Error building",
|
||||
buildLink,
|
||||
userId: compose.project.userId,
|
||||
organizationId: compose.project.organizationId,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
@@ -430,10 +430,10 @@ export const rebuildRemoteCompose = async ({
|
||||
});
|
||||
|
||||
try {
|
||||
const admin = await findUserById(compose.project.userId);
|
||||
if (admin.cleanupCacheOnCompose) {
|
||||
await cleanupFullDocker(compose?.serverId);
|
||||
}
|
||||
// const admin = await findUserById(compose.project.userId);
|
||||
// if (admin.cleanupCacheOnCompose) {
|
||||
// await cleanupFullDocker(compose?.serverId);
|
||||
// }
|
||||
if (compose.serverId) {
|
||||
await getBuildComposeCommand(compose, deployment.logPath);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { db } from "@dokploy/server/db";
|
||||
import {
|
||||
type apiCreatePreviewDeployment,
|
||||
deployments,
|
||||
organization,
|
||||
previewDeployments,
|
||||
} from "@dokploy/server/db/schema";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
@@ -154,11 +155,14 @@ export const createPreviewDeployment = async (
|
||||
const application = await findApplicationById(schema.applicationId);
|
||||
const appName = `preview-${application.appName}-${generatePassword(6)}`;
|
||||
|
||||
const org = await db.query.organization.findFirst({
|
||||
where: eq(organization.id, application.project.organizationId),
|
||||
});
|
||||
const generateDomain = await generateWildcardDomain(
|
||||
application.previewWildcard || "*.traefik.me",
|
||||
appName,
|
||||
application.server?.ipAddress || "",
|
||||
application.project.userId,
|
||||
org?.ownerId || "",
|
||||
);
|
||||
|
||||
const octokit = authGithub(application?.github as Github);
|
||||
|
||||
@@ -5,8 +5,6 @@ import {
|
||||
execAsync,
|
||||
execAsyncRemote,
|
||||
} from "@dokploy/server/utils/process/execAsync";
|
||||
import { findAdminById } from "./admin";
|
||||
// import packageInfo from "../../../package.json";
|
||||
|
||||
export interface IUpdateData {
|
||||
latestVersion: string | null;
|
||||
|
||||
@@ -20,17 +20,17 @@ export type User = typeof users_temp.$inferSelect;
|
||||
// };
|
||||
|
||||
export const findUserByAuthId = async (authId: string) => {
|
||||
const userR = await db.query.user.findFirst({
|
||||
where: eq(user.id, authId),
|
||||
with: {},
|
||||
});
|
||||
if (!userR) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "User not found",
|
||||
});
|
||||
}
|
||||
return userR;
|
||||
// const userR = await db.query.user.findFirst({
|
||||
// where: eq(user.id, authId),
|
||||
// with: {},
|
||||
// });
|
||||
// if (!userR) {
|
||||
// throw new TRPCError({
|
||||
// code: "NOT_FOUND",
|
||||
// message: "User not found",
|
||||
// });
|
||||
// }
|
||||
// return userR;
|
||||
};
|
||||
|
||||
export const findUsers = async (adminId: string) => {
|
||||
|
||||
@@ -18,7 +18,7 @@ interface Props {
|
||||
applicationType: string;
|
||||
errorMessage: string;
|
||||
buildLink: string;
|
||||
userId: string;
|
||||
organizationId: string;
|
||||
}
|
||||
|
||||
export const sendBuildErrorNotifications = async ({
|
||||
@@ -27,14 +27,14 @@ export const sendBuildErrorNotifications = async ({
|
||||
applicationType,
|
||||
errorMessage,
|
||||
buildLink,
|
||||
userId,
|
||||
organizationId,
|
||||
}: Props) => {
|
||||
const date = new Date();
|
||||
const unixDate = ~~(Number(date) / 1000);
|
||||
const notificationList = await db.query.notifications.findMany({
|
||||
where: and(
|
||||
eq(notifications.appBuildError, true),
|
||||
eq(notifications.userId, userId),
|
||||
eq(notifications.organizationId, organizationId),
|
||||
),
|
||||
with: {
|
||||
email: true,
|
||||
|
||||
@@ -18,7 +18,7 @@ interface Props {
|
||||
applicationName: string;
|
||||
applicationType: string;
|
||||
buildLink: string;
|
||||
userId: string;
|
||||
organizationId: string;
|
||||
domains: Domain[];
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export const sendBuildSuccessNotifications = async ({
|
||||
applicationName,
|
||||
applicationType,
|
||||
buildLink,
|
||||
userId,
|
||||
organizationId,
|
||||
domains,
|
||||
}: Props) => {
|
||||
const date = new Date();
|
||||
@@ -35,7 +35,7 @@ export const sendBuildSuccessNotifications = async ({
|
||||
const notificationList = await db.query.notifications.findMany({
|
||||
where: and(
|
||||
eq(notifications.appDeploy, true),
|
||||
eq(notifications.userId, userId),
|
||||
eq(notifications.organizationId, organizationId),
|
||||
),
|
||||
with: {
|
||||
email: true,
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from "./utils";
|
||||
|
||||
export const sendDockerCleanupNotifications = async (
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
message = "Docker cleanup for dokploy",
|
||||
) => {
|
||||
const date = new Date();
|
||||
@@ -21,7 +21,7 @@ export const sendDockerCleanupNotifications = async (
|
||||
const notificationList = await db.query.notifications.findMany({
|
||||
where: and(
|
||||
eq(notifications.dockerCleanup, true),
|
||||
eq(notifications.userId, userId),
|
||||
eq(notifications.organizationId, organizationId),
|
||||
),
|
||||
with: {
|
||||
email: true,
|
||||
|
||||
Reference in New Issue
Block a user