diff --git a/.gitignore b/.gitignore index 2800e13..322e33b 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ packages/api/db.switchfeat.segments db.switchfeat.flags db.switchfeat.segments db.switchfeat.users +db.switchfeat.sdkauths +packages/api/db.switchfeat.sdkauths diff --git a/package-lock.json b/package-lock.json index ba4b1b9..26af740 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20943,6 +20943,7 @@ "express": "^4.18.2", "express-session": "^1.17.3", "multer": "^1.4.5-lts.1", + "nanoid": "^4.0.2", "passport": "^0.6.0", "passport-google-oauth20": "^2.0.0", "path": "^0.12.7" @@ -21163,6 +21164,23 @@ "node": ">=4.0" } }, + "packages/api/node_modules/nanoid": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-4.0.2.tgz", + "integrity": "sha512-7ZtY5KTCNheRGfEFxnedV5zFiORN1+Y1N6zvPTnHQd8ENUvfaDBeuJDZb2bN/oXwXxu3qkTXDzy57W5vAmDTBw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.js" + }, + "engines": { + "node": "^14 || ^16 || >=18" + } + }, "packages/core": { "name": "@switchfeat/core", "version": "0.0.1", diff --git a/package.json b/package.json index b55c570..ed15e9f 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "lint": "lerna run lint", "start": "cd ./packages/api && npm run start", "dev:start-api": "cd ./packages/api && npm run dev:start", - "dev:start-ui": "cd ./packages/ui && npm run start" + "dev:start-ui": "cd ./packages/ui && npm run start", + "dev:start-tw:watch": "cd ./packages/ui && npm run build:tw:watch" } } diff --git a/packages/api/src/helpers/responseHelper.ts b/packages/api/src/helpers/responseHelper.ts new file mode 100644 index 0000000..d97ff0d --- /dev/null +++ b/packages/api/src/helpers/responseHelper.ts @@ -0,0 +1,27 @@ +import { ApiResponseCode, ResponseModel } from "@switchfeat/core"; +import { Request, Response } from "express"; + +export const setErrorResponse = (resp: Response, error: ApiResponseCode) => { + console.log(error); + resp.status(error.statusCode).json({ + success: false, + error: error, + data: null + } as ResponseModel); +}; + +export const setSuccessResponse = (resp: Response, code: ApiResponseCode, data: T, req?: Request) => { + console.log(code); + + const response = { + success: true, + data + } as ResponseModel; + + if (req) { + response.user = req.user; + response.cookies = req.cookies; + } + + resp.status(code.statusCode).json(response); +}; \ No newline at end of file diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index f31669a..90b5566 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -12,6 +12,7 @@ import * as passportAuth from "./managers/auth/passportAuth"; import { getDataStoreManager } from './managers/auth/dataStoreManager'; import { segmentsRoutesWrapper } from './routes/segmentsRoutes'; import { sdkRoutesWrapper } from './routes/sdkRoutes'; +import { sdkAuthRoutesWrapper } from './routes/sdkAuthRoutes'; dotenv.config(); const env = process.env.NODE_ENV; @@ -52,6 +53,7 @@ app.use(authRoutes); app.use(flagRoutesWrapper(dataStoreManagerPromise)); app.use(segmentsRoutesWrapper(dataStoreManagerPromise)); app.use(sdkRoutesWrapper(dataStoreManagerPromise)); +app.use(sdkAuthRoutesWrapper(dataStoreManagerPromise)); if (env !== "dev") { app.get('*', (req, res) => { diff --git a/packages/api/src/managers/auth/passportAuth.ts b/packages/api/src/managers/auth/passportAuth.ts index 1348f82..972973e 100644 --- a/packages/api/src/managers/auth/passportAuth.ts +++ b/packages/api/src/managers/auth/passportAuth.ts @@ -1,40 +1,68 @@ import passport from "passport"; import { Request, Response, NextFunction, Express } from "express"; import * as userService from "../../services/usersService"; +import * as sdkAuthService from "../../services/sdkAuthService"; import { googleStrategy } from "./googleAuth"; -import { keys } from "@switchfeat/core"; +import { ApiResponseCodes, keys } from "@switchfeat/core"; export const initialise = (app: Express) => { - app.use(passport.initialize()); + app.use(passport.initialize()); - // serialize the user.id to save in the cookie session - // so the browser will remember the user when login - passport.serializeUser((_req, user, done) => { - done(null, user); - }); + // serialize the user.id to save in the cookie session + // so the browser will remember the user when login + passport.serializeUser((_req, user, done) => { + done(null, user); + }); - // deserialize the cookieUserId to user in the database - passport.deserializeUser(async (id: string, done) => { - const currentUser = await userService.getUser({ userId: id }); - done(currentUser === null ? "user not found." : null, { user: currentUser }); - }); + // deserialize the cookieUserId to user in the database + passport.deserializeUser(async (id: string, done) => { + const currentUser = await userService.getUser({ userId: id }); + done(currentUser === null ? "user not found." : null, { user: currentUser }); + }); - if (keys.AUTH_PROVIDER === "google") { - console.log(" -> Google auth active"); - passport.use(googleStrategy()); - } + if (keys.AUTH_PROVIDER === "google") { + console.log(" -> Google auth active"); + passport.use(googleStrategy()); + } }; +export const isAuthenticated = (req: Request, res: Response, next: NextFunction) => { + if (!keys.AUTH_PROVIDER || req.isAuthenticated()) { + return next(); + } + res.redirect("/"); +}; +/* +** - Get the apikey from the sdk request +** - Lookup of the key in db +** - Ensure it is not expired +*/ +export const isSdkAuthenticated = async (req: Request, res: Response, next: NextFunction) => { + const apiKey = req.headers["sf-api-key"] as string; + if (!apiKey) { + res.status(401).json({ + error: ApiResponseCodes.ApiKeyNotFound, + }); -export const isAuthenticated = (req: Request, res: Response, next: NextFunction) => { - if (!keys.AUTH_PROVIDER || req.isAuthenticated()) { - return next(); - } - res.redirect("/"); + return; + } + const foundInDb = await sdkAuthService.getSdkAuth({ apiKey: apiKey }); + + const isValid = foundInDb !== null && foundInDb.expiresOn > new Date(); + + if (!keys.AUTH_PROVIDER && isValid) { + return next(); + } + + res.status(401).json({ + error: ApiResponseCodes.ApiKeyNotValid + }); + + return; }; diff --git a/packages/api/src/routes/authRoutes.ts b/packages/api/src/routes/authRoutes.ts index face918..5d28d68 100644 --- a/packages/api/src/routes/authRoutes.ts +++ b/packages/api/src/routes/authRoutes.ts @@ -1,7 +1,8 @@ import { NextFunction, Router } from "express"; import passport from "passport"; -import { keys } from "@switchfeat/core"; +import { ApiResponseCodes, keys } from "@switchfeat/core"; import { Request, Response } from "express"; +import { setErrorResponse } from "../helpers/responseHelper"; export const authRoutes = Router(); @@ -34,10 +35,7 @@ authRoutes.get("/auth/is-auth", (req: Request, res: Response) => { cookies: req.cookies }); } else { - res.status(401).json({ - success: false, - message: "user failed to authenticate." - }); + setErrorResponse(res, ApiResponseCodes.UserAuthFailed); } } }); diff --git a/packages/api/src/routes/flagsRoutes.ts b/packages/api/src/routes/flagsRoutes.ts index 6502367..684d447 100644 --- a/packages/api/src/routes/flagsRoutes.ts +++ b/packages/api/src/routes/flagsRoutes.ts @@ -4,7 +4,8 @@ import multer from 'multer'; import * as flagsService from "../services/flagsService"; import * as auth from "../managers/auth/passportAuth"; -import { dateHelper, dbManager, entityHelper } from "@switchfeat/core"; +import { ApiResponseCodes, dateHelper, dbManager, entityHelper } from "@switchfeat/core"; +import { setErrorResponse, setSuccessResponse } from "../helpers/responseHelper"; export const flagRoutesWrapper = (storeManager: Promise) : Router => { @@ -17,19 +18,9 @@ export const flagRoutesWrapper = (storeManager: Promise) : Router => { + + sdkAuthService.setDataStoreManager(storeManager); + + const upload = multer(); + const apiAuthRoutes = Router(); + apiAuthRoutes.get("/api/sdk/auth/", auth.isAuthenticated, async (req: Request, res: Response) => { + + try { + const sdkAuths = await sdkAuthService.getSdkAuths(""); + + // hide the full apikey for security reasons + sdkAuths.forEach(x => { + x.apiKey = x.apiKey.substring(0, 3) + 'ยทยทยท' + x.apiKey.substring(x.apiKey.length - 5, x.apiKey.length - 1); + }); + + setSuccessResponse(res, ApiResponseCodes.Success, sdkAuths, req); + } catch (error) { + setErrorResponse(res, ApiResponseCodes.GenericError); + } + }); + + apiAuthRoutes.post("/api/sdk/auth/", upload.any(), auth.isAuthenticated, async (req: Request, res: Response) => { + + console.log("received sdkAuth add: " + JSON.stringify(req.body)); + const keyName = req.body.keyName; + const keyExpiresOn = req.body.keyExpiresOn; + + if (!keyName) { + setErrorResponse(res, ApiResponseCodes.InputMissing); + return; + } + + const apiAuthKey = entityHelper.generateGuid("apikey"); + + const alreadyInDb = await sdkAuthService.getSdkAuth({ key: apiAuthKey }); + + if (!alreadyInDb) { + const apiKey = await entityHelper.generateGuid("sk"); + + await sdkAuthService.addSdkAuth({ + name: keyName, + createdOn: dateHelper.utcNow().toJSDate(), + expiresOn: keyExpiresOn ? new Date(keyExpiresOn) : dateHelper.utcNow().plus({months: 12}).toJSDate(), + updatedOn: dateHelper.utcNow().toJSDate(), + key: apiAuthKey, + apiKey: apiKey + }); + + setSuccessResponse(res, ApiResponseCodes.Success, {apikey: apiKey}, req); + } else { + setErrorResponse(res, ApiResponseCodes.SdkAuthKeyNotFound); + } + }); + + + apiAuthRoutes.delete("/api/sdk/auth/", upload.any(), auth.isAuthenticated, async (req: Request, res: Response) => { + + console.log("received sdkAuth delete: " + JSON.stringify(req.body)); + + const apiAuthKey = req.body.sdkAuthKey; + + if (!apiAuthKey) { + setErrorResponse(res, ApiResponseCodes.InputMissing); + return; + } + + const alreadyInDb = await sdkAuthService.getSdkAuth({ key: apiAuthKey }); + + if (alreadyInDb) { + await sdkAuthService.deleteSdkAuth(alreadyInDb); + setSuccessResponse(res, ApiResponseCodes.Success, null, req); + } else { + setErrorResponse(res, ApiResponseCodes.SdkAuthKeyNotFound); + } + }); + + return apiAuthRoutes; +}; diff --git a/packages/api/src/routes/sdkRoutes.ts b/packages/api/src/routes/sdkRoutes.ts index 79f3f30..9901a22 100644 --- a/packages/api/src/routes/sdkRoutes.ts +++ b/packages/api/src/routes/sdkRoutes.ts @@ -4,8 +4,9 @@ import multer from 'multer'; import * as flagsService from "../services/flagsService"; import * as segmentsService from "../services/segmentsService"; import * as auth from "../managers/auth/passportAuth"; -import { SdkResponseCodes, dbManager } from "@switchfeat/core"; +import { ApiResponseCodes, dbManager } from "@switchfeat/core"; import * as sdkService from "../services/sdkService"; +import { setErrorResponse, setSuccessResponse } from "../helpers/responseHelper"; export const sdkRoutesWrapper = (storeManager: Promise): Router => { @@ -14,97 +15,78 @@ export const sdkRoutesWrapper = (storeManager: Promise { + sdkRoutes.get("/api/sdk/flags", auth.isSdkAuthenticated, async (req: Request, res: Response) => { try { const flags = await flagsService.getFlags(""); - res.json({ - user: req.user, - flags: flags - }); + setSuccessResponse(res, ApiResponseCodes.Success, flags); } catch (error) { - console.log(error); - res.status(500).json({ - error: SdkResponseCodes.GenericError - }); + setErrorResponse(res, ApiResponseCodes.GenericError); } }); - sdkRoutes.post("/api/sdk/flag", upload.any(), auth.isAuthenticated, async (req: Request, res: Response) => { + sdkRoutes.get("/api/sdk/flags/:flagKey", auth.isSdkAuthenticated, async (req: Request, res: Response) => { try { - const flagKey = req.body.flagKey; - const flagContext = req.body.flagContext; - const correlationId = req.body.correlationId; - + const flagKey = req.params.flagKey; const flag = await flagsService.getFlag({key: flagKey}); if (!flag) { - res.json({ - user: req.user, - error: SdkResponseCodes.FlagNotFound - }); + setErrorResponse(res, ApiResponseCodes.FlagNotFound); + return; + } + + setSuccessResponse(res, ApiResponseCodes.Success, flag); + } catch (error) { + setErrorResponse(res, ApiResponseCodes.GenericError); + } + }); + + sdkRoutes.post("/api/sdk/flags/:flagKey/evaluate", upload.any(), auth.isSdkAuthenticated, async (req: Request, res: Response) => { + try { + const flagKey = req.params.flagKey; + const flagContext = req.body.context; + const correlationId = req.body.correlationId; + const flag = await flagsService.getFlag({key: flagKey}); + + if (!flag || !flagContext) { + setErrorResponse(res, ApiResponseCodes.FlagNotFound); return; } const resp = await sdkService.evaluateFlag(flag, flagContext, correlationId); - - res.json({ - user: req.user, - data: resp - }); + setSuccessResponse(res, ApiResponseCodes.Success, resp); } catch (error) { - res.json({ - user: req.user, - error: SdkResponseCodes.GenericError - }); + setErrorResponse(res, ApiResponseCodes.GenericError); } }); - sdkRoutes.get("/api/sdk/flags/:flagKey/rules", auth.isAuthenticated, async (req: Request, res: Response) => { + sdkRoutes.get("/api/sdk/flags/:flagKey/rules", auth.isSdkAuthenticated, async (req: Request, res: Response) => { try { const flagKey = req.params.flagKey as string; const rules = await flagsService.getRulesByFlag(flagKey); - res.json({ - user: req.user, - rules: rules - }); + setSuccessResponse(res, ApiResponseCodes.Success, rules, req); } catch (error) { - console.log(error); - res.status(500).json({ - error: SdkResponseCodes.GenericError - }); + setErrorResponse(res, ApiResponseCodes.GenericError); } }); - sdkRoutes.get("/api/sdk/segments", auth.isAuthenticated, async (req: Request, res: Response) => { + sdkRoutes.get("/api/sdk/segments", auth.isSdkAuthenticated, async (req: Request, res: Response) => { try { const segments = await segmentsService.getSegments(""); - res.json({ - user: req.user, - segments: segments - }); + setSuccessResponse(res, ApiResponseCodes.Success, segments); } catch (error) { - console.log(error); - res.status(500).json({ - error: SdkResponseCodes.GenericError - }); + setErrorResponse(res, ApiResponseCodes.GenericError); } }); - sdkRoutes.get("/api/sdk/segments/:segmentKey/conditions", auth.isAuthenticated, async (req: Request, res: Response) => { + sdkRoutes.get("/api/sdk/segments/:segmentKey/conditions", auth.isSdkAuthenticated, async (req: Request, res: Response) => { try { const segmentKey = req.params.segmentKey as string; const conditions = await segmentsService.getConditionsBySegment(segmentKey); - res.json({ - user: req.user, - conditions: conditions - }); + setSuccessResponse(res, ApiResponseCodes.Success, conditions); } catch (error) { - console.log(error); - res.status(500).json({ - error: SdkResponseCodes.GenericError - }); + setErrorResponse(res, ApiResponseCodes.GenericError); } }); return sdkRoutes; diff --git a/packages/api/src/routes/segmentsRoutes.ts b/packages/api/src/routes/segmentsRoutes.ts index b446c38..51fb1f8 100644 --- a/packages/api/src/routes/segmentsRoutes.ts +++ b/packages/api/src/routes/segmentsRoutes.ts @@ -4,7 +4,8 @@ import multer from 'multer'; import * as segmentsService from "../services/segmentsService"; import * as auth from "../managers/auth/passportAuth"; -import { dateHelper, dbManager, entityHelper, SegmentMatching } from "@switchfeat/core"; +import { dateHelper, dbManager, entityHelper, ApiResponseCodes, SegmentMatching } from "@switchfeat/core"; +import { setErrorResponse, setSuccessResponse } from "../helpers/responseHelper"; export const segmentsRoutesWrapper = (storeManager: Promise): Router => { @@ -15,18 +16,9 @@ export const segmentsRoutesWrapper = (storeManager: Promise { try { const segments = await segmentsService.getSegments(""); - res.json({ - success: true, - user: req.user, - cookies: req.cookies, - segments: segments - }); + setSuccessResponse(res, ApiResponseCodes.Success, segments, req); } catch (error) { - console.log(error); - res.status(500).json({ - success: false, - message: "unable to retrieve conditions" - }); + setErrorResponse(res, ApiResponseCodes.GenericError); } }); @@ -40,10 +32,7 @@ export const segmentsRoutesWrapper = (storeManager: Promise { @@ -76,10 +62,7 @@ export const segmentsRoutesWrapper = (storeManager: Promise) => { + manager.then(data => dataStoreManager = data); +}; + +export const getSdkAuths = async (userKey: string): Promise => { + return await dataStoreManager.getSdkAuths(userKey); +}; + +export const getSdkAuth = async (search: { key?: string, apiKey?: string }): Promise => { + + if (search.key) { + return await dataStoreManager.getSdkAuthByKey(search.key); + } + + if (search.apiKey) { + return await dataStoreManager.getSdkAuthByApiKey(search.apiKey); + } + + return null; +}; + + +export const addSdkAuth = async (auth: SdkAuthModel): Promise => { + return await dataStoreManager.addSdkAuth(auth); +}; + +export const deleteSdkAuth = async (auth: SdkAuthModel): Promise => { + return await dataStoreManager.deleteSdkAuth(auth); +}; \ No newline at end of file diff --git a/packages/api/src/services/sdkService.ts b/packages/api/src/services/sdkService.ts index 2091aaa..87ca5d0 100644 --- a/packages/api/src/services/sdkService.ts +++ b/packages/api/src/services/sdkService.ts @@ -1,4 +1,4 @@ -import { ResponseCode, FlagModel, SdkResponseCodes, dateHelper } from "@switchfeat/core"; +import { ApiResponseCode, FlagModel, ApiResponseCodes, dateHelper } from "@switchfeat/core"; import { ConditionModel, StringOperator } from "@switchfeat/core"; import {v4 as uuidv4} from 'uuid'; @@ -8,7 +8,7 @@ export type EvaluateResponse = { segment: string | null; condition: string | null; } - reason: ResponseCode; + reason: ApiResponseCode; time: number; correlationId: string; responseId: string; @@ -22,7 +22,7 @@ export const evaluateFlag = async (flag: FlagModel, context: Record { +export const generateSlug = (val: string) => { return val.toLowerCase() .replace(/ /g, "-") .replace(/[^\w-]+/g, ""); }; - export const generateGuid = (prefix: string) => { + export const generateGuid = (prefix?: string) => { const guid = uuidv4(); - return `${prefix}_${guid}`; + const prefixVal = prefix ? prefix + "_" : ""; + return `${prefixVal}${guid}`; }; \ No newline at end of file diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 2f84d2b..bbe4686 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -3,7 +3,9 @@ export { RuleModel } from "./models/ruleModel"; export { ConditionModel, StringOperator, BooleanOperator, NumericOperator, DayTimeOperator } from "./models/conditionModel"; export { SegmentModel, SegmentMatching } from "./models/segmentModel"; export { UserModel } from "./models/userModel"; -export { SdkResponseCodes, ResponseCode } from "./models/responseCodes"; +export { SdkAuthModel } from "./models/sdkAuthModel"; +export { ResponseModel } from "./models/responseModel"; +export { ApiResponseCodes, ApiResponseCode } from "./models/apiResponseCodes"; export * as dbManager from "./managers/data/dbManager"; export * from "./config/keys"; export * as dateHelper from "./helpers/dateHelper"; diff --git a/packages/core/src/managers/data/NeDb/sdkAuthNeDbManager.ts b/packages/core/src/managers/data/NeDb/sdkAuthNeDbManager.ts new file mode 100644 index 0000000..561b7a3 --- /dev/null +++ b/packages/core/src/managers/data/NeDb/sdkAuthNeDbManager.ts @@ -0,0 +1,38 @@ +import { SdkAuthModel } from "../../../models/sdkAuthModel"; +import { NeDbManager } from "../dbManager"; + +let neDbManager: NeDbManager; + +export const setNeDbManager = (neDbGlobalManager: NeDbManager) => { + neDbManager = neDbGlobalManager; +}; + +export const getSdkAuths = async (userKey?: string): Promise => neDbManager.sdkAuths!.asyncFind({}); + +export const getSdkAuthByKey = async (key: string): Promise => neDbManager.sdkAuths!.asyncFindOne({ + key, +}); + +export const getSdkAuthByApiKey = async (apiKey: string): Promise => neDbManager.sdkAuths!.asyncFindOne({ + apiKey, +}); + +export const addSdkAuth = async (auth: SdkAuthModel): Promise => { + try { + const response = await neDbManager.sdkAuths!.asyncInsert(auth); + return (!!response); + } catch (ex) { + console.error(ex); + return false; + } +}; + +export const deleteSdkAuth = async (auth: SdkAuthModel): Promise => { + try { + const response = await neDbManager.sdkAuths!.asyncRemove({ _id: auth._id }); + return (!!response); + } catch (ex) { + console.error(ex); + return false; + } +}; \ No newline at end of file diff --git a/packages/core/src/managers/data/dbManager.ts b/packages/core/src/managers/data/dbManager.ts index 66e399e..80a1b7b 100644 --- a/packages/core/src/managers/data/dbManager.ts +++ b/packages/core/src/managers/data/dbManager.ts @@ -5,6 +5,7 @@ import AsyncNedb from "nedb-async"; import { createMongoDataStore } from "./mongoManager"; import { createNeDbDataStore } from "./neDBManager"; import { SegmentModel } from "../../models/segmentModel"; +import { SdkAuthModel } from "../../models/sdkAuthModel"; export enum SupportedDb { Mongo = "mongo", @@ -21,12 +22,14 @@ export type MongoManager = { flags: Collection | null, users: Collection | null, segments: Collection | null, + sdkAuths: Collection | null, }; export type NeDbManager = { flags: AsyncNedb | null, users: AsyncNedb | null, - segments: AsyncNedb | null, + segments: AsyncNedb | null + sdkAuths: AsyncNedb | null, }; export type DataStoreManager = { @@ -54,6 +57,13 @@ export type DataStoreManager = { addUser: (user: UserModel) => Promise; updateUser: (user: UserModel) => Promise; deleteUser: (user: UserModel) => Promise; + + // api auth functions + getSdkAuths: (userKey?: string) => Promise; + getSdkAuthByKey: (authKey: string) => Promise; + getSdkAuthByApiKey: (authKey: string) => Promise; + addSdkAuth: (apiKey: SdkAuthModel) => Promise; + deleteSdkAuth: (apiKey: SdkAuthModel) => Promise; }; type DbManager = MongoManager | NeDbManager; diff --git a/packages/core/src/managers/data/mongoManager.ts b/packages/core/src/managers/data/mongoManager.ts index 1b7541f..7d8fd46 100644 --- a/packages/core/src/managers/data/mongoManager.ts +++ b/packages/core/src/managers/data/mongoManager.ts @@ -32,6 +32,11 @@ export const createMongoDataStore = async (): Promise => { getSegmentById: () => { throw new Error(); }, getSegmentByKey: () => { throw new Error(); }, updateSegment: () => { throw new Error(); }, + getSdkAuths: () => { throw new Error(); }, + getSdkAuthByKey: () => { throw new Error(); }, + getSdkAuthByApiKey: () => { throw new Error(); }, + addSdkAuth: () => { throw new Error(); }, + deleteSdkAuth: () => { throw new Error(); }, }; return dataStoreInstance; @@ -50,6 +55,7 @@ const connectDb = async (): Promise => { mongoDbManager.users = db.collection("users"); mongoDbManager.flags = db.collection("flags"); mongoDbManager.segments = db.collection("segments"); + mongoDbManager.sdkAuths = db.collection("sdkAuths"); return mongoDbManager; diff --git a/packages/core/src/managers/data/neDBManager.ts b/packages/core/src/managers/data/neDBManager.ts index 31192dc..24c5315 100644 --- a/packages/core/src/managers/data/neDBManager.ts +++ b/packages/core/src/managers/data/neDBManager.ts @@ -6,7 +6,9 @@ import { DataStoreManager, NeDbManager, SupportedDb, getDbManager } from "./dbMa import * as flagsManager from "./NeDb/flagsNeDbManager"; import * as usersManager from "./NeDb/usersNeDbManager"; import * as segmentsManager from "./NeDb/segmentsNeDbManager"; +import * as sdkAuthsManager from "./NeDb/sdkAuthNeDbManager"; import { SegmentModel } from '../../models/segmentModel'; +import { SdkAuthModel } from '../../models/sdkAuthModel'; export const createNeDbDataStore = async (): Promise => { @@ -19,6 +21,7 @@ export const createNeDbDataStore = async (): Promise => { flagsManager.setNeDbManager(neDbManager); usersManager.setNeDbManager(neDbManager); segmentsManager.setNeDbManager(neDbManager); + sdkAuthsManager.setNeDbManager(neDbManager); const dataStoreInstance = { addFlag: flagsManager.addFlag, @@ -34,7 +37,13 @@ export const createNeDbDataStore = async (): Promise => { deleteSegment: segmentsManager.deleteSegment, getSegmentById: segmentsManager.getSegmentById, getSegmentByKey: segmentsManager.getSegmentByKey, - updateSegment: segmentsManager.updateSegment, + updateSegment: segmentsManager.updateSegment, + + getSdkAuths: sdkAuthsManager.getSdkAuths, + getSdkAuthByKey: sdkAuthsManager.getSdkAuthByKey, + getSdkAuthByApiKey: sdkAuthsManager.getSdkAuthByApiKey, + addSdkAuth: sdkAuthsManager.addSdkAuth, + deleteSdkAuth: sdkAuthsManager.deleteSdkAuth, getUser: () => { throw new Error(); }, getUserByEmail: () => { throw new Error(); }, @@ -52,6 +61,7 @@ const connectDb = async (): Promise => { neDbManager.flags = new AsyncNedb({ filename: 'db.switchfeat.flags', autoload: true }); neDbManager.users = new AsyncNedb({ filename: 'db.switchfeat.users', autoload: true }); neDbManager.segments = new AsyncNedb({ filename: 'db.switchfeat.segments', autoload: true }); + neDbManager.sdkAuths = new AsyncNedb({ filename: 'db.switchfeat.sdkauths', autoload: true }); console.log(`NeDBManager: connectDB: connected to local neDB`); diff --git a/packages/core/src/models/apiResponseCodes.ts b/packages/core/src/models/apiResponseCodes.ts new file mode 100644 index 0000000..7b019d0 --- /dev/null +++ b/packages/core/src/models/apiResponseCodes.ts @@ -0,0 +1,50 @@ + +export type ApiResponseCode = { + message: string; + statusCode: number; + description?: string; +}; + +type ApiResponseCodes = { + GenericError: ApiResponseCode, + ConditionNotFound: ApiResponseCode, + SegmentNotFound: ApiResponseCode, + RuleNotFound: ApiResponseCode, + FlagNotFound: ApiResponseCode, + NoMatchingCondition: ApiResponseCode, + ApiKeyNotValid: ApiResponseCode, + ApiKeyNotFound: ApiResponseCode, + ApiKeyExpired: ApiResponseCode, + FlagDisabled: ApiResponseCode, + FlagMatch: ApiResponseCode, + InputMissing: ApiResponseCode, + SdkAuthKeyNotFound: ApiResponseCode, + UserAuthFailed: ApiResponseCode, + Success: ApiResponseCode +}; + +export const ApiResponseCodes: ApiResponseCodes = { + + GenericError: { message: "generic_error", statusCode: 500 }, + + ConditionNotFound: { message: "condition_not_found", statusCode: 404 }, + FlagNotFound: { message: "flag_not_found", statusCode: 404 }, + SegmentNotFound: { message: "segment_not_found", statusCode: 404 }, + RuleNotFound: { message: "rule_not_found", statusCode: 404 }, + + NoMatchingCondition: { message: "no_matching_condition", statusCode: 400 }, + InputMissing: { message: "input_missing", statusCode: 400 }, + + ApiKeyNotValid: { message: "api_key_not_valid", statusCode: 401 }, + ApiKeyExpired: { message: "api_key_expired", statusCode: 401 }, + ApiKeyNotFound: { message: "api_key_not_found", statusCode: 401, description: "Add the 'sf-api-key' header with your api key to the request" }, + + SdkAuthKeyNotFound: { message: "sdkauth_key_not_found", statusCode: 404 }, + + UserAuthFailed: { message: "user_auth_failed", statusCode: 401 }, + + FlagDisabled: { message: "flag_disabled", statusCode: 200 }, + FlagMatch: { message: "flag_match", statusCode: 200 }, + + Success: { message: "generic_success", statusCode: 200 } +} as const; \ No newline at end of file diff --git a/packages/core/src/models/responseCodes.ts b/packages/core/src/models/responseCodes.ts deleted file mode 100644 index 68ed3de..0000000 --- a/packages/core/src/models/responseCodes.ts +++ /dev/null @@ -1,33 +0,0 @@ - -export type ResponseCode = { - message: string; - statusCode: number; -}; - -type ResponseCodes = { - GenericError: ResponseCode, - ConditionNotFound: ResponseCode, - SegmentNotFound: ResponseCode, - FlagNotFound: ResponseCode, - NoMatchingCondition: ResponseCode, - ApiKeyNotValid: ResponseCode, - RuleNotFound: ResponseCode, - FlagDisabled: ResponseCode, - FlagMatch: ResponseCode, -}; - -export const SdkResponseCodes: ResponseCodes = { - - // errors - GenericError: { message: "generic_error", statusCode: 500 }, - ConditionNotFound: { message: "condition_not_found", statusCode: 404 }, - FlagNotFound: { message: "flag_not_found", statusCode: 404 }, - NoMatchingCondition: { message: "no_matching_condition", statusCode: 400 }, - SegmentNotFound: { message: "segment_not_found", statusCode: 404 }, - ApiKeyNotValid: { message: "apikey_not_valid", statusCode: 401 }, - RuleNotFound: { message: "rule_not_found", statusCode: 404 }, - - // info - FlagDisabled: { message: "flag_disabled", statusCode: 200 }, - FlagMatch: { message: "flag_match", statusCode: 200 }, -} as const; \ No newline at end of file diff --git a/packages/core/src/models/responseModel.ts b/packages/core/src/models/responseModel.ts new file mode 100644 index 0000000..3c62d97 --- /dev/null +++ b/packages/core/src/models/responseModel.ts @@ -0,0 +1,9 @@ +import { ApiResponseCode } from "./apiResponseCodes"; + +export type ResponseModel = { + success: boolean; + error?: ApiResponseCode; + data: T, + user?: unknown; + cookies?: unknown; +}; \ No newline at end of file diff --git a/packages/core/src/models/sdkAuthModel.ts b/packages/core/src/models/sdkAuthModel.ts new file mode 100644 index 0000000..9fdaf8e --- /dev/null +++ b/packages/core/src/models/sdkAuthModel.ts @@ -0,0 +1,6 @@ +import { BaseModel } from "./baseModel"; + +export type SdkAuthModel = { + expiresOn: Date; + apiKey: string; +} & BaseModel; \ No newline at end of file diff --git a/packages/core/src/models/userModel.ts b/packages/core/src/models/userModel.ts index 4d4f63f..d6640a5 100644 --- a/packages/core/src/models/userModel.ts +++ b/packages/core/src/models/userModel.ts @@ -1,11 +1,7 @@ -import { ObjectId } from 'mongodb'; +import { BaseModel } from './baseModel'; -export interface UserModel { - _id?: ObjectId, - name: string; +export type UserModel = { email?: string; - createdOn: Date; - updatedOn: Date; isBlocked: boolean; imageUrl: string | undefined; -} \ No newline at end of file +} & BaseModel; \ No newline at end of file diff --git a/packages/ui/package.json b/packages/ui/package.json index 14b7381..2bc0222 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -31,6 +31,7 @@ "test": "react-scripts test", "eject": "react-scripts eject", "build:tw": "tailwindcss -i ./src/App.css -o ./src/output.css", + "build:tw:watch": "tailwindcss -i ./src/App.css -o ./src/output.css --watch", "prestart": "npm run build:tw", "lint": "eslint . --ext .tsx" }, diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index a5aa46f..e9c9b91 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -7,6 +7,7 @@ import { Segments } from "./pages/Segments"; import { Dashboard } from "./pages/Dashboard"; import PrivateRoute from "./components/shared/PrivateRoute"; import { NotificationProvider } from "./components/shared/NotificationProvider"; +import { ApiKeys } from "./components/settings/apiKeys/ApiKeys"; const App: React.FC = () => { return ( @@ -39,6 +40,14 @@ const App: React.FC = () => { } /> + + + + } + /> diff --git a/packages/ui/src/components/flags/FlagsItem.tsx b/packages/ui/src/components/flags/FlagsItem.tsx index 5acf3fe..f453545 100644 --- a/packages/ui/src/components/flags/FlagsItem.tsx +++ b/packages/ui/src/components/flags/FlagsItem.tsx @@ -82,18 +82,16 @@ export const FlagsItem: React.FC<{ flag: FlagModel, handleRefreshFlags: () => vo
-
+
{props.flag.name} ({props.flag.description})
- key: {props.flag.key} - - + key: {props.flag.key}
-
{dateHelper.formatDateTime(props.flag.createdOn)}
+
{dateHelper.formatDateTime(props.flag.createdOn, true)}
= (props) => { }).then(respJson => { setSegments([]); const allSegments: SegmentModel[] = []; - respJson.segments.forEach((item: SegmentModel) => { + respJson.data.forEach((item: SegmentModel) => { allSegments.push({ name: item.name, description: item.description, diff --git a/packages/ui/src/components/segments/SegmentsItem.tsx b/packages/ui/src/components/segments/SegmentsItem.tsx index 3c8a126..f434baa 100644 --- a/packages/ui/src/components/segments/SegmentsItem.tsx +++ b/packages/ui/src/components/segments/SegmentsItem.tsx @@ -21,20 +21,20 @@ export const SegmentsItem: React.FC<{ segment: SegmentModel, handleRefreshSegmen
setOpenEdit(!openEdit)} >
-
+
{props.segment.name} ( {props.segment.description} )
- key: {props.segment.key} + key: {props.segment.key}
- {props.segment.conditions.length} conditions + {props.segment.conditions.length} conditions
- matching {props.segment.matching} + matching {props.segment.matching}
diff --git a/packages/ui/src/components/settings/apiKeys/ApiKeys.tsx b/packages/ui/src/components/settings/apiKeys/ApiKeys.tsx new file mode 100644 index 0000000..c5feb27 --- /dev/null +++ b/packages/ui/src/components/settings/apiKeys/ApiKeys.tsx @@ -0,0 +1,112 @@ +import { FC, useEffect } from "react"; +import { Settings } from "../../../pages/Settings"; +import { useApiKeys } from "./hooks"; +import { SdkAuthModel } from "../../../models/SdkAuthModel"; +import { classNames } from "../../../helpers/classHelper"; +import * as dateHelper from "../../../helpers/dateHelper"; +import { CreateApiKey } from "./CreateApiKey"; +import { DeleteApiKey } from "./DeleteApiKey"; + + +export const ApiKeys: FC = () => { + + const hookState = useApiKeys(); + + return ( + +
+
+
+

API Keys

+

+ Please note, we do not display your secret API keys again after you generate them. +

+
+
+ +
+
+
+ + + + + + + + + + + + + {hookState.sdkAuths.map((x: SdkAuthModel, idx: number) => ( + + + + + + + + + + + ))} + +
+ Name + + Key + + Expires On + + Last used on + + Select +
+ {x.name} + + {x.apiKey} + + {dateHelper.formatDateTime(x.expiresOn, true)} + + {dateHelper.formatDateTime(x.createdOn, true)} + + + {idx !== 0 ?
: null} +
+
+
+
+ ); +}; \ No newline at end of file diff --git a/packages/ui/src/components/settings/apiKeys/CreateApiKey.tsx b/packages/ui/src/components/settings/apiKeys/CreateApiKey.tsx new file mode 100644 index 0000000..7094937 --- /dev/null +++ b/packages/ui/src/components/settings/apiKeys/CreateApiKey.tsx @@ -0,0 +1,142 @@ +import { Transition, Dialog } from "@headlessui/react"; +import { KeyIcon } from "@heroicons/react/24/outline"; +import { ElementRef, FC, Fragment, useRef, useState } from "react"; +import { toast } from 'react-hot-toast'; +import { UseApiKeysProps } from "./hooks"; + +export const CreateApiKey: FC<{hookState: UseApiKeysProps}> = (props) => { + const [open, setOpen] = useState(false); + const nameRef = useRef>(null); + const [keyGenerated, setKeyGenerated] = useState(""); + + const handleGenerateApiKey = async () => { + + if (!nameRef.current) { + return; + } + + const apiKey = await props.hookState.generateApiKey(nameRef.current.value); + + if (apiKey) { + setKeyGenerated(apiKey); + } else { + toast.error("Error creating API Key, please try again."); + setOpen(false); + } + }; + + const handleGeneratedKeyOnClose = () => { + setOpen(false); + setKeyGenerated(null); + props.hookState.doRefreshSdkAuths(); + }; + + return ( + <> + + + + + +
+ + +
+
+ + +
+
+
+
+ + Create API Key + + + {!keyGenerated ? ( + <> + + + + + ) : ( +
+

Please save your new API key somewhere safe. For security reasons, + you won't be able to see it again. + If you lose this secret key, you'll need to generate a new one.

+
+ {keyGenerated} + +
+ +
+ )} + +
+
+
+ {!keyGenerated ? ( + + + ) : ( + + )} + +
+
+
+
+
+
+
+ + ); +}; diff --git a/packages/ui/src/components/settings/apiKeys/DeleteApiKey.tsx b/packages/ui/src/components/settings/apiKeys/DeleteApiKey.tsx new file mode 100644 index 0000000..4d9ea87 --- /dev/null +++ b/packages/ui/src/components/settings/apiKeys/DeleteApiKey.tsx @@ -0,0 +1,98 @@ +import { Transition, Dialog } from "@headlessui/react"; +import { TrashIcon, XMarkIcon } from "@heroicons/react/24/outline"; +import { FC, Fragment, useState } from "react"; +import { UseApiKeysProps } from "./hooks"; +import { toast } from 'react-hot-toast'; + +export const DeleteApiKey: FC<{sdkAuthKey: string, hookState: UseApiKeysProps}> = (props) => { + const [open, setOpen] = useState(false); + + const handleDeleteApiKey = async () => { + const success = await props.hookState.deleteApiKey(props.sdkAuthKey); + + if (success) { + toast.success("API Key successfully deleted."); + props.hookState.doRefreshSdkAuths(); + } else { + toast.error("Error deleting API Key, please try again."); + } + + setOpen(false); + }; + + return ( + <> + + + + + +
+ + +
+
+ + +
+
+
+
+ + Delete API Key + +

+ This API key will immediately be disabled. + API requests made using this key will be rejected, + which could break any functionality depending on it. +

+
+
+
+ + +
+
+
+
+
+
+
+ + + ); +}; diff --git a/packages/ui/src/components/settings/apiKeys/hooks.ts b/packages/ui/src/components/settings/apiKeys/hooks.ts new file mode 100644 index 0000000..fdf8a86 --- /dev/null +++ b/packages/ui/src/components/settings/apiKeys/hooks.ts @@ -0,0 +1,122 @@ +import { useEffect, useState } from "react"; +import * as keys from "../../../config/keys"; +import { SdkAuthModel } from "../../../models/SdkAuthModel"; + +export type UseApiKeysProps = { + sdkAuths: SdkAuthModel[]; + generateApiKey: (keyName: string) => Promise; + setRefreshSdkAuths: (state: boolean) => void; + loading: boolean; + deleteApiKey: (sdkAuthKey: string) => Promise; + doRefreshSdkAuths: () => void; +}; + +export const useApiKeys = (): UseApiKeysProps => { + const [sdkAuths, setSdkAuths] = useState([]); + const [refreshSdkAuths, setRefreshSdkAuths] = useState(true); + const [loading, setLoading] = useState(false); + + useEffect(() => { + setLoading(true); + fetch(`${keys.CLIENT_HOME_PAGE_URL}/api/sdk/auth/`, { + method: "GET", + credentials: "include", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Origin": "true" + } + }).then(async resp => { + return resp.json(); + }).then(respJson => { + setSdkAuths([]); + const allData: SdkAuthModel[] = []; + respJson.data.forEach((item: SdkAuthModel) => { + allData.push({ + name: item.name, + description: item.description, + createdOn: item.createdOn, + updatedOn: item.updatedOn, + key: item.key, + expiresOn: item.expiresOn, + apiKey: item.apiKey + }); + }); + setSdkAuths([...allData]); + setLoading(false); + }).catch(ex => { console.log(ex); }); + }, [refreshSdkAuths]); + + + const generateApiKey = async (keyName: string): Promise => { + const formData = new FormData(); + formData.append('keyName', keyName); + + try { + const resp = await fetch(`${keys.CLIENT_HOME_PAGE_URL}/api/sdk/auth/`, { + method: "POST", + credentials: "include", + headers: { + Accept: "application/json", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Origin": "true" + }, + body: formData + }); + const respJson = await resp.json(); + + if (respJson.success) { + setSdkAuths((apiKeys) => [...apiKeys, respJson.data]); + // doRefreshSdkAuths(); + return respJson.data.apikey; + } + } catch (error) { + console.log(error); + } + + return null; + }; + + const deleteApiKey = async (sdkAuthKey: string): Promise => { + const formData = new FormData(); + formData.append('sdkAuthKey', sdkAuthKey); + + try { + const resp = await fetch(`${keys.CLIENT_HOME_PAGE_URL}/api/sdk/auth/`, { + method: "DELETE", + credentials: "include", + headers: { + Accept: "application/json", + "Access-Control-Allow-Credentials": "true", + "Access-Control-Allow-Origin": "true" + }, + body: formData + }); + const respJson = await resp.json(); + + if (respJson.success) { + doRefreshSdkAuths(); + } + + return respJson.success; + } catch (error) { + console.log(error); + } + + return false; + }; + + const doRefreshSdkAuths = () => { + setRefreshSdkAuths(!refreshSdkAuths); + }; + + return { + sdkAuths, + generateApiKey, + setRefreshSdkAuths, + loading, + deleteApiKey, + doRefreshSdkAuths + }; +}; \ No newline at end of file diff --git a/packages/ui/src/components/shared/NotificationProvider.tsx b/packages/ui/src/components/shared/NotificationProvider.tsx index ed5d53c..b387fbb 100644 --- a/packages/ui/src/components/shared/NotificationProvider.tsx +++ b/packages/ui/src/components/shared/NotificationProvider.tsx @@ -95,13 +95,13 @@ export const NotificationProvider = ({ leaveFrom="opacity-100 scale-100" leaveTo="opacity-0 scale-75" > -
+
-

{resolveValue(t.message, t)}

+

{resolveValue(t.message, t)}

-
+ {(t as Toast).subMessage && (

{(t as Toast).subMessage}

-
+
)} )} diff --git a/packages/ui/src/helpers/dateHelper.ts b/packages/ui/src/helpers/dateHelper.ts index 9f0dc7c..18616cb 100644 --- a/packages/ui/src/helpers/dateHelper.ts +++ b/packages/ui/src/helpers/dateHelper.ts @@ -1,11 +1,13 @@ import { DateTime } from "luxon"; -export const formatDateTime = (dateStr: string | undefined) => { +export const formatDateTime = (dateStr: string | undefined, hideTime?: boolean) => { if (dateStr === undefined) { return ""; - } + } - return DateTime.fromISO(dateStr).toFormat("MMM dd, yyyy @ hh:mm a"); + return DateTime.fromISO(dateStr).toFormat(hideTime ? + "MMM dd, yyyy" : + "MMM dd, yyyy @ hh:mm a"); }; export const utcNow = (): DateTime => { diff --git a/packages/ui/src/layout/DashboardLayout.tsx b/packages/ui/src/layout/DashboardLayout.tsx index 2e472f6..8fd47e7 100644 --- a/packages/ui/src/layout/DashboardLayout.tsx +++ b/packages/ui/src/layout/DashboardLayout.tsx @@ -21,8 +21,7 @@ export const DashboardLayout: React.FC<{ children: ReactNode }> = (props) => { const navigation = [ { name: 'Dashboard', href: '/dashboard', icon: HomeIcon }, { name: 'Flags', href: '/flags', icon: FlagIcon }, - { name: 'Segments', href: '/segments', icon: FolderIcon }, - // { name: 'Conditions', href: '/conditions', icon: ArrowsRightLeftIcon } + { name: 'Segments', href: '/segments', icon: FolderIcon } ]; return ( @@ -110,7 +109,7 @@ export const DashboardLayout: React.FC<{ children: ReactNode }> = (props) => {
  • = (props) => {
  • { }).then(respJson => { setFlags([]); const allFlags: FlagModel[] = []; - respJson.flags.forEach((item: FlagModel) => { + respJson.data.forEach((item: FlagModel) => { allFlags.push({ name: item.name, description: item.description, diff --git a/packages/ui/src/pages/Segments.tsx b/packages/ui/src/pages/Segments.tsx index 23e1805..7550e6d 100644 --- a/packages/ui/src/pages/Segments.tsx +++ b/packages/ui/src/pages/Segments.tsx @@ -35,7 +35,7 @@ export const Segments: React.FC = () => { }).then(respJson => { setSegments([]); const allSegments: SegmentModel[] = []; - respJson.segments.forEach((item: SegmentModel) => { + respJson.data.forEach((item: SegmentModel) => { allSegments.push({ name: item.name, description: item.description, diff --git a/packages/ui/src/pages/Settings.tsx b/packages/ui/src/pages/Settings.tsx new file mode 100644 index 0000000..d38e0ea --- /dev/null +++ b/packages/ui/src/pages/Settings.tsx @@ -0,0 +1,37 @@ +import { FC, ReactNode } from "react"; +import { classNames } from "../helpers/classHelper"; +import { DashboardLayout } from "../layout/DashboardLayout"; + + +export const Settings: FC<{ children: ReactNode }> = (props) => { + + const secondaryNavigation = [ + { name: 'API Keys', href: '/settings/apikeys', current: true } + ]; + + return ( + +
    + {/* Secondary navigation */} + +
    +
    + + {props.children} + +
    + + ); +}; \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index f8ebba0..4760065 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,10 +7,11 @@ "noLib": false, "emitDecoratorMetadata": true, "experimentalDecorators": true, - "target": "es2022", + "target": "ES2022", "moduleResolution": "node", "strict": true, - "sourceMap": true + "sourceMap": true, + "esModuleInterop": true }, "exclude": [ "node_modules",