├── .eslintrc.json
├── .example.env
├── .gitignore
├── Dockerfile
├── README.md
├── components
├── account
│ ├── account-switcher-button.tsx
│ └── index.tsx
├── audit-log
│ ├── index.tsx
│ └── log-renderer
│ │ └── index.tsx
├── color-mode-switcher
│ └── index.tsx
├── fire-feature
│ └── index.tsx
├── footer
│ ├── copyright.tsx
│ ├── index.tsx
│ └── sm-links.tsx
├── index.ts
├── login
│ ├── divider-with-text.tsx
│ ├── index.tsx
│ ├── login-form.tsx
│ └── underline-link.tsx
├── logo
│ └── index.tsx
├── navbar
│ ├── index.tsx
│ ├── mobile-nav.tsx
│ └── nav-link.tsx
├── project-card
│ └── index.tsx
├── projects
│ ├── details-section
│ │ ├── heat-renderer
│ │ │ └── index.tsx
│ │ ├── heats-selection
│ │ │ └── index.tsx
│ │ └── index.tsx
│ ├── index.tsx
│ └── project-section
│ │ └── index.tsx
├── toggle-button-group
│ └── index.tsx
└── toggle-button
│ └── index.tsx
├── context
└── flags-context.tsx
├── cypress.env.json
├── docker-compose.yml
├── hooks
├── index.ts
├── useAppUrl
│ └── index.ts
├── useFlagMutation
│ └── index.ts
├── useHeatMutation
│ └── index.ts
├── useLogs
│ └── index.ts
├── useProject
│ └── index.ts
├── useProjectMutation
│ └── index.ts
└── useProjects
│ └── index.ts
├── img
└── app-demo.png
├── lib
├── pino.ts
└── prisma.ts
├── mocks
├── browser.ts
├── handlers.ts
├── index.ts
└── server.ts
├── next-env.d.ts
├── package.json
├── pages
├── _app.tsx
├── _document.tsx
├── api
│ ├── auth
│ │ └── [...nextauth].ts
│ ├── flag
│ │ ├── create
│ │ │ └── index.ts
│ │ └── update
│ │ │ └── index.ts
│ ├── flags
│ │ └── [projectId].ts
│ ├── heat
│ │ ├── create
│ │ │ └── index.ts
│ │ ├── delete
│ │ │ └── index.ts
│ │ └── update
│ │ │ └── index.ts
│ ├── logs
│ │ └── [id].ts
│ ├── project
│ │ ├── [projectId].ts
│ │ ├── create
│ │ │ └── index.ts
│ │ └── update
│ │ │ └── index.ts
│ └── projects
│ │ └── index.ts
├── index.tsx
├── projects
│ └── [id].tsx
└── signin.tsx
├── pino.json
├── prisma
├── migrations
│ ├── 20210724070207_inital
│ │ └── migration.sql
│ ├── 20210806140102_add_projects_to_schema
│ │ └── migration.sql
│ ├── 20210820191030_add_is_archived_field_to_the_project
│ │ └── migration.sql
│ ├── 20210823191108_add_heats_to_schema
│ │ └── migration.sql
│ ├── 20210824170027_add_created_and_updated_columns_to_heats
│ │ └── migration.sql
│ ├── 20210904201333_add_new_heats_structure
│ │ └── migration.sql
│ ├── 20210904203126_removed_default_fields
│ │ └── migration.sql
│ ├── 20210904205403_audit_logs
│ │ └── migration.sql
│ ├── 20210905072832_add_user_to_audit_log
│ │ └── migration.sql
│ ├── 20210906125329_correct_mandatory_fields
│ │ └── migration.sql
│ └── migration_lock.toml
├── schema.prisma
└── seed.ts
├── public
├── logo.svg
└── mockServiceWorker.js
├── tsconfig.json
├── types
└── next-auth.d.ts
└── yarn.lock
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "plugins": ["@typescript-eslint"],
3 | "extends": [
4 | "next",
5 | "next/core-web-vitals",
6 | "plugin:@typescript-eslint/recommended"
7 | ],
8 | "rules": {
9 | "@typescript-eslint/no-unused-vars": "error",
10 | "@typescript-eslint/explicit-module-boundary-types":"off"
11 | }
12 | }
--------------------------------------------------------------------------------
/.example.env:
--------------------------------------------------------------------------------
1 | DATABASE_URL=postgresql://postgres:secret@localhost:4343/flags?schema=public
2 | NEXT_AUTH_SECRET=RANDOM_STRING
3 | NEXTAUTH_URL=http://localhost:3000/api/auth
4 | SMTP_HOST=smtp.sendgrid.net
5 | SMTP_PORT=587
6 | SMTP_USER=apikey
7 | SMTP_PASSWORD=XXXX
8 | SMTP_FROM=hey@flags.com
9 | GITHUB_CLIENT_ID=XXXX
10 | GITHUB_CLIENT_SECRET=XXXX
11 | TWITTER_CONSUMER_KEY=XXXX
12 | TWITTER_CONSUMER_SECRET=XXXX
13 | NODE_ENV=development
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | .env
29 | .env.development.local
30 | .env.test.local
31 | .env.production.local
32 |
33 | # vercel
34 | .vercel
35 |
36 | # database
37 | db
38 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:alpine
2 |
3 | WORKDIR /home/node/app
4 | COPY package*.json ./
5 | COPY . .
6 | RUN yarn add prisma -g
7 |
8 | EXPOSE 3000
9 | CMD ["sh", "-c", "yarn vercel-build ; yarn start"]
10 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | 
2 |
3 | Suggest new features here
4 |
5 |
6 |
7 |
8 |
9 | Dead simple and blazing fast feature-flags platform. Get started in minutes. Be confident when releasing new features for your application - you are one kill switch away from disabling the feature if it breaks. Just turn it off now and fix it later 👌
10 |
11 | 
12 |
13 | https://user-images.githubusercontent.com/29632358/133240482-2bdb08be-b41a-406c-9e1c-df6064e0d2cd.mp4
14 |
15 |
16 | ## Get started with managed version
17 |
18 | - Visit [flags.stackonfire.dev](https://flags.stackonfire.dev) and sign in with any convenient method.
19 | - Create a new project
20 | - Create a new flag, rename it and add description if needed
21 | - Copy the link from the project page and make a request to that url to retrieve your feature flags
22 |
23 | Here is a simple implementation of how you might user the feature.
24 |
25 | ### Use with React
26 |
27 | [Official fire-flags library for react](https://github.com/stack-on-fire/react-fire-flags) - engineered by [Alfredo Salzillo](https://github.com/alfredosalzillo)
28 |
29 | ## Get started with self-hosted version
30 |
31 | Fire flags is dead simple to self host! You need to have an instance of Postgres database running, as long as you have the connection string you can safely deploy to Vercel. Environments variables necessary to run the app are listed in `.example.env`
32 |
33 | Fire-flags currently offers three methods of authentication - magic link, github and twitter. The auth setup is powered by Next-auth and to make it work you need to provide correct environment variables to the project.
34 |
35 | > Contributions for dockerised version are highly welcome
36 |
37 | ## Contribute
38 |
39 | - Clone the repo
40 | - run `docker-compose up -d` in the terminal to boot up the database
41 | - development environment uses MSW to mock session, so you don't need to set up next-auth related env variables to be able to log in. If you need to mimic login workflow - disable service workers in `_app.tsx`
42 | - run `yarn run dev`
43 |
--------------------------------------------------------------------------------
/components/account/account-switcher-button.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | Flex,
4 | FlexProps,
5 | HStack,
6 | useMediaQuery,
7 | useMenuButton,
8 | } from "@chakra-ui/react";
9 | import * as React from "react";
10 | import Avatar from "boring-avatars";
11 | import { HiSelector } from "react-icons/hi";
12 | import { User } from "@prisma/client";
13 |
14 | export const AccountSwitcherButton = (props: FlexProps & { user: User }) => {
15 | const buttonProps = useMenuButton(props);
16 | const [isSmallerThan600px] = useMediaQuery("(max-width: 600px)");
17 |
18 | return (
19 |
35 |
36 |
42 | {!isSmallerThan600px && (
43 |
44 |
45 | {props.user.email || props.user.name}
46 |
47 |
48 | )}
49 |
50 |
51 |
52 |
53 |
54 | );
55 | };
56 |
--------------------------------------------------------------------------------
/components/account/index.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Menu,
3 | MenuItem,
4 | MenuList,
5 | useColorModeValue,
6 | Switch,
7 | Stack,
8 | Flex,
9 | Box,
10 | useColorMode,
11 | MenuDivider,
12 | } from "@chakra-ui/react";
13 | import * as React from "react";
14 | import { AccountSwitcherButton } from "./account-switcher-button";
15 | import { signOut } from "next-auth/client";
16 | import { useRouter } from "next/dist/client/router";
17 | import { MoonIcon, SunIcon } from "@chakra-ui/icons";
18 |
19 | export const AccountSwitcher = ({ session }) => {
20 | const { colorMode, toggleColorMode } = useColorMode();
21 |
22 | const router = useRouter();
23 | return (
24 |
57 | );
58 | };
59 |
--------------------------------------------------------------------------------
/components/audit-log/index.tsx:
--------------------------------------------------------------------------------
1 | import { Heading } from "@chakra-ui/react";
2 | import { AuditLog, FeatureFlag, User } from "@prisma/client";
3 | import { useLogs } from "hooks/useLogs";
4 | import { useRouter } from "next/dist/client/router";
5 | import React from "react";
6 | import LogRenderer from "./log-renderer";
7 |
8 | const AuditLogComponent = () => {
9 | const router = useRouter();
10 | const {
11 | data: logs,
12 | }: {
13 | data: ReadonlyArray<
14 | AuditLog & { before: FeatureFlag; after: FeatureFlag; User: User }
15 | >;
16 | } = useLogs({ id: router.query.flag });
17 |
18 | return (
19 | <>
20 |
21 | Audit log
22 |
23 | {logs?.map((log) => {
24 | return ;
25 | })}
26 | >
27 | );
28 | };
29 |
30 | export default AuditLogComponent;
31 |
--------------------------------------------------------------------------------
/components/audit-log/log-renderer/index.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Stack,
3 | Flex,
4 | Circle,
5 | Text,
6 | useColorModeValue,
7 | Heading,
8 | } from "@chakra-ui/react";
9 | import { AuditLog, FeatureFlag, User } from "@prisma/client";
10 | import React from "react";
11 | import { format } from "date-fns";
12 |
13 | const typeToTitle = {
14 | FLAG_CREATE: "Created feature flag",
15 | FLAG_UPDATE: "Updated feature flag",
16 | HEAT_CREATE: "Created heat for flag",
17 | HEAT_UPDATE: "Updated heat for flag",
18 | HEAT_DELETE: "Deleted heat for flag",
19 | };
20 |
21 | const LogRenderer = ({
22 | log,
23 | }: {
24 | log: AuditLog & { after: FeatureFlag; before: FeatureFlag; User: User };
25 | }) => {
26 | return (
27 |
28 |
29 |
43 |
44 |
45 |
46 |
47 | {typeToTitle[log.type]}
48 |
49 |
50 | {log.User.name}
51 |
52 |
53 | {format(new Date(log.createdAt), "PP p")}
54 |
55 |
56 |
57 |
58 | );
59 | };
60 |
61 | export default LogRenderer;
62 |
--------------------------------------------------------------------------------
/components/color-mode-switcher/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { MoonIcon, SunIcon } from "@chakra-ui/icons";
3 | import { Stack } from "@chakra-ui/layout";
4 | import { Switch } from "@chakra-ui/switch";
5 | import { useColorMode } from "@chakra-ui/color-mode";
6 |
7 | export const ColorModeSwitcher = () => {
8 | const { colorMode, toggleColorMode } = useColorMode();
9 |
10 | return (
11 |
12 |
13 |
18 |
19 |
20 | );
21 | };
22 |
--------------------------------------------------------------------------------
/components/fire-feature/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useFlags } from "context/flags-context";
3 |
4 | const FireFeature = ({ flagName, children }) => {
5 | const flags = useFlags();
6 | const foundFlag = flags.find((flag) => flag.name === flagName);
7 | if (!foundFlag) return null;
8 | return {foundFlag.isActive ? children : null}
;
9 | };
10 |
11 | export default FireFeature;
12 |
--------------------------------------------------------------------------------
/components/footer/copyright.tsx:
--------------------------------------------------------------------------------
1 | import { Text, TextProps } from "@chakra-ui/layout";
2 | import * as React from "react";
3 |
4 | export const Copyright = (props: TextProps) => (
5 |
6 | © {new Date().getFullYear()} Stack on Fire, Inc. All rights reserved.
7 |
8 | );
9 |
--------------------------------------------------------------------------------
/components/footer/index.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Stack } from "@chakra-ui/react";
2 | import * as React from "react";
3 | import { Copyright } from "./copyright";
4 | import { Logo } from "components/logo";
5 | import { SocialMediaLinks } from "./sm-links";
6 |
7 | export const Footer = () => (
8 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | );
25 |
--------------------------------------------------------------------------------
/components/footer/sm-links.tsx:
--------------------------------------------------------------------------------
1 | import { ButtonGroup, ButtonGroupProps, IconButton } from "@chakra-ui/react";
2 | import * as React from "react";
3 | import { FaGithub, FaLinkedin, FaTwitter } from "react-icons/fa";
4 |
5 | export const SocialMediaLinks = (props: ButtonGroupProps) => (
6 |
7 | }
12 | />
13 | }
18 | />
19 | }
24 | />
25 |
26 | );
27 |
--------------------------------------------------------------------------------
/components/index.ts:
--------------------------------------------------------------------------------
1 | import { Footer } from "./footer";
2 | import { Navbar } from "./navbar";
3 | import { Login } from "./login";
4 |
5 | export { Footer, Navbar, Login };
6 |
--------------------------------------------------------------------------------
/components/login/divider-with-text.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | Divider,
4 | Flex,
5 | FlexProps,
6 | Text,
7 | useColorModeValue,
8 | } from "@chakra-ui/react";
9 | import * as React from "react";
10 |
11 | export const DividerWithText = (props: FlexProps) => {
12 | const { children, ...flexProps } = props;
13 | return (
14 |
15 |
16 |
17 |
18 |
24 | {children}
25 |
26 |
27 |
28 |
29 |
30 | );
31 | };
32 |
--------------------------------------------------------------------------------
/components/login/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {
3 | Box,
4 | Button,
5 | Center,
6 | Heading,
7 | SimpleGrid,
8 | Stack,
9 | useColorModeValue,
10 | VisuallyHidden,
11 | } from "@chakra-ui/react";
12 |
13 | import { LoginForm } from "./login-form";
14 | import { Logo } from "components/logo";
15 | import { DividerWithText } from "./divider-with-text";
16 | import { FaGithub, FaTwitter } from "react-icons/fa";
17 | import { signIn } from "next-auth/client";
18 |
19 | type Props = {
20 | variant: "signin";
21 | };
22 |
23 | export const Login = ({ variant }: Props) => {
24 | return (
25 | <>
26 | {" "}
27 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
47 | {variant === "signin" && "Sign in to your account"}
48 |
49 |
53 | We will send you a magic link
54 |
55 |
56 |
57 |
58 |
59 |
60 | or continue with
61 |
62 |
63 |
73 |
85 |
86 |
87 | >
88 | );
89 | };
90 |
--------------------------------------------------------------------------------
/components/login/login-form.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | Button,
4 | Flex,
5 | FormControl,
6 | FormLabel,
7 | Input,
8 | LightMode,
9 | Stack,
10 | useColorModeValue as mode,
11 | } from "@chakra-ui/react";
12 | import { useAppUrl } from "hooks/useAppUrl";
13 | import { signIn } from "next-auth/client";
14 | import Link from "next/link";
15 | import * as React from "react";
16 | import { UnderlineLink } from "./underline-link";
17 |
18 | type Props = {
19 | variant: "signin" | "signup" | "forgot-password";
20 | };
21 |
22 | export const LoginForm = ({ variant }: Props) => {
23 | const [email, setEmail] = React.useState("");
24 | const [isLoading, setIsLoading] = React.useState(false);
25 | const appUrl = useAppUrl();
26 |
27 | return (
28 |
104 | );
105 | };
106 |
--------------------------------------------------------------------------------
/components/login/underline-link.tsx:
--------------------------------------------------------------------------------
1 | import { Box, BoxProps } from "@chakra-ui/react";
2 | import * as React from "react";
3 |
4 | export const UnderlineLink = (props: BoxProps) => {
5 | return (
6 |
25 | );
26 | };
27 |
--------------------------------------------------------------------------------
/components/logo/index.tsx:
--------------------------------------------------------------------------------
1 | import { Heading, Stack } from "@chakra-ui/react";
2 | import * as React from "react";
3 | import Image from "next/image";
4 |
5 | export const Logo = () => {
6 | return (
7 |
8 | <>
9 |
10 | Fire Flags
11 | >
12 |
13 | );
14 | };
15 |
--------------------------------------------------------------------------------
/components/navbar/index.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | Button,
4 | HStack,
5 | useColorModeValue as mode,
6 | VisuallyHidden,
7 | Skeleton,
8 | } from "@chakra-ui/react";
9 |
10 | import Link from "next/link";
11 | import * as React from "react";
12 |
13 | import { Logo } from "components/logo";
14 | import { useSession } from "next-auth/client";
15 | import { AccountSwitcher } from "components/account";
16 | import { useRouter } from "next/dist/client/router";
17 | export const Navbar = () => {
18 | const [session, loading] = useSession();
19 | const router = useRouter();
20 |
21 | const signInComponent = session ? (
22 |
23 |
24 |
25 | ) : (
26 |
27 |
28 |
29 | );
30 | return (
31 |
32 |
33 |
34 | router.push("/", null, { shallow: true })}
36 | cursor="pointer"
37 | >
38 | Stack on fire
39 |
40 |
41 |
42 |
43 | {loading ? : signInComponent}
44 |
45 |
46 |
47 | );
48 | };
49 |
--------------------------------------------------------------------------------
/components/navbar/mobile-nav.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | Button,
4 | Center,
5 | Flex,
6 | Portal,
7 | SimpleGrid,
8 | useBoolean,
9 | useFocusOnShow,
10 | VStack,
11 | useColorModeValue as mode,
12 | } from "@chakra-ui/react";
13 | import { Logo } from "components/logo";
14 | import { HTMLMotionProps, motion, Variants } from "framer-motion";
15 | import * as React from "react";
16 | import FocusLock from "react-focus-lock";
17 | import {
18 | HiBookOpen,
19 | HiCurrencyDollar,
20 | HiOutlineMenu,
21 | HiOutlineX,
22 | } from "react-icons/hi";
23 | import { RemoveScroll } from "react-remove-scroll";
24 | import { NavLink } from "./nav-link";
25 | import { signIn } from "next-auth/client";
26 |
27 | const variants: Variants = {
28 | show: {
29 | display: "revert",
30 | opacity: 1,
31 | scale: 1,
32 | transition: { duration: 0.2, ease: "easeOut" },
33 | },
34 | hide: {
35 | opacity: 0,
36 | scale: 0.98,
37 | transition: { duration: 0.1, ease: "easeIn" },
38 | transitionEnd: { display: "none" },
39 | },
40 | };
41 |
42 | const Backdrop = ({ show }: { show?: boolean }) => (
43 |
44 |
60 |
61 | );
62 |
63 | const Transition = (props: HTMLMotionProps<"div"> & { in?: boolean }) => {
64 | const { in: inProp, ...rest } = props;
65 | return (
66 |
81 | );
82 | };
83 |
84 | export const MobileNav = () => {
85 | const [show, { toggle, off }] = useBoolean();
86 | const ref = React.useRef(null);
87 | useFocusOnShow(ref, { visible: show, shouldFocus: true });
88 |
89 | return (
90 | <>
91 |
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
116 |
117 |
118 |
119 |
120 |
129 | Close menu
130 |
131 |
132 |
133 |
134 |
135 | Features
136 | Pricing
137 |
138 |
139 |
142 |
143 | Have an account?{" "}
144 | signIn()}
146 | as="a"
147 | color={mode("blue.600", "blue.400")}
148 | >
149 | Log in
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 | >
158 | );
159 | };
160 |
--------------------------------------------------------------------------------
/components/navbar/nav-link.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | chakra,
4 | Flex,
5 | HTMLChakraProps,
6 | Icon,
7 | useColorModeValue as mode,
8 | } from "@chakra-ui/react";
9 | import * as React from "react";
10 |
11 | interface DesktopNavLinkProps extends HTMLChakraProps<"a"> {
12 | active?: boolean;
13 | }
14 |
15 | const DesktopNavLink = (props: DesktopNavLinkProps) => {
16 | const { active, ...rest } = props;
17 | return (
18 |
29 | );
30 | };
31 |
32 | interface MobileNavLinkProps {
33 | icon: React.ElementType;
34 | children: React.ReactNode;
35 | href?: string;
36 | }
37 |
38 | const MobileNavLink = (props: MobileNavLinkProps) => {
39 | const { icon, children, href } = props;
40 | return (
41 |
51 |
52 |
53 | {children}
54 |
55 |
56 | );
57 | };
58 |
59 | export const NavLink = {
60 | Desktop: DesktopNavLink,
61 | Mobile: MobileNavLink,
62 | };
63 |
--------------------------------------------------------------------------------
/components/project-card/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Box } from "@chakra-ui/layout";
3 | import {
4 | Flex,
5 | HStack,
6 | Icon,
7 | Text,
8 | useColorModeValue as mode,
9 | } from "@chakra-ui/react";
10 | import { FaFire } from "react-icons/fa";
11 | import Link from "next/link";
12 | import { FeatureFlag, Project } from "@prisma/client";
13 | import BoringAvatar from "boring-avatars";
14 | import { HiArchive } from "react-icons/hi";
15 |
16 | const ProjectCard = ({
17 | project,
18 | }: {
19 | project: Project & { featureFlags: ReadonlyArray };
20 | }) => {
21 | const numberOfActiveFlags = project.featureFlags.filter(
22 | (flag) => !flag.isArchived && flag.isActive
23 | ).length;
24 |
25 | return (
26 |
27 |
40 |
41 |
42 |
49 |
50 |
56 | {project.name}
57 |
58 |
59 |
60 | {project.description}
61 |
62 |
63 |
64 | {project.isArchived && (
65 |
66 | )}
67 |
68 |
73 | {numberOfActiveFlags} active flag
74 | {numberOfActiveFlags > 1 ? "s" : ""}
75 |
76 |
77 |
78 |
79 | );
80 | };
81 |
82 | export default ProjectCard;
83 |
--------------------------------------------------------------------------------
/components/projects/details-section/heat-renderer/index.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | Text,
4 | Checkbox,
5 | CheckboxGroup,
6 | HStack,
7 | Input,
8 | Button,
9 | Tag,
10 | Flex,
11 | TagLabel,
12 | TagCloseButton,
13 | } from "@chakra-ui/react";
14 | import { Heat } from "@prisma/client";
15 | import { useHeatMutation } from "hooks";
16 | import { useAppUrl } from "hooks/useAppUrl";
17 |
18 | import React, { useState } from "react";
19 | import { useEffect } from "react";
20 | import toast from "react-hot-toast";
21 | import { useMutation, useQueryClient } from "react-query";
22 |
23 | const HeatRenderer = ({ heat }: { heat: Heat }) => {
24 | const appUrl = useAppUrl();
25 | const queryClient = useQueryClient();
26 | const [values, setValues] = useState([]);
27 | const [stringInput, setStringInput] = useState("");
28 |
29 | const heatMutation = useHeatMutation();
30 |
31 | const deleteHeatMutation = useMutation(
32 | async () => {
33 | const result = await fetch(`${appUrl}/api/heat/delete?id=${heat.id}`);
34 | const json = await result.json();
35 | return json;
36 | },
37 | {
38 | onSuccess: async () => {
39 | await queryClient.refetchQueries(["projects"]);
40 | },
41 | }
42 | );
43 |
44 | useEffect(() => {
45 | setValues(heat.values);
46 | }, [heat]);
47 |
48 | if (heat.type === "ENVIRONMENT") {
49 | return (
50 |
51 |
52 |
53 | Environment
54 | {" "}
55 |
73 |
90 |
91 | setValues(e)}
94 | colorScheme="gray"
95 | >
96 |
97 | Production
98 | Development
99 | Staging
100 |
101 |
102 |
103 | );
104 | }
105 |
106 | if (heat.type === "USER_INCLUDE" || heat.type === "USER_EXCLUDE") {
107 | return (
108 |
109 |
110 |
111 | {(heat.type === "USER_EXCLUDE" && "Exclude users with IDs") ||
112 | (heat.type === "USER_INCLUDE" && "Include users with IDs")}
113 |
114 |
134 |
151 |
152 |
153 | {heat.values.map((id) => {
154 | return (
155 | <>
156 |
166 | {id}
167 | {
169 | heatMutation.mutate(
170 | {
171 | id: heat.id,
172 | values: heat.values.filter((userId) => userId !== id),
173 | deleteValues: true,
174 | },
175 | {
176 | onSuccess: () => {
177 | toast.success("Successfully modified the heat");
178 | },
179 | onError: () => {
180 | toast.error("Error happened");
181 | },
182 | }
183 | );
184 | }}
185 | />
186 |
187 | >
188 | );
189 | })}
190 |
191 |
192 | setStringInput(e.target.value)}
197 | />
198 |
199 |
200 | );
201 | }
202 | if (heat.type === "CUSTOM") {
203 | return (
204 |
205 |
206 |
207 | {heat.name}
208 |
209 |
229 |
246 |
247 |
248 | {heat.values.map((id) => {
249 | return (
250 | <>
251 |
261 | {id}
262 | {
264 | heatMutation.mutate(
265 | {
266 | id: heat.id,
267 | values: heat.values.filter((userId) => userId !== id),
268 | deleteValues: true,
269 | },
270 | {
271 | onSuccess: () => {
272 | toast.success("Successfully modified the heat");
273 | },
274 | onError: () => {
275 | toast.error("Error happened");
276 | },
277 | }
278 | );
279 | }}
280 | />
281 |
282 | >
283 | );
284 | })}
285 |
286 |
287 | setStringInput(e.target.value)}
292 | />
293 |
294 |
295 | );
296 | }
297 | };
298 |
299 | export default HeatRenderer;
300 |
--------------------------------------------------------------------------------
/components/projects/details-section/heats-selection/index.tsx:
--------------------------------------------------------------------------------
1 | import { Box, Radio, RadioGroup, VStack } from "@chakra-ui/react";
2 | import React from "react";
3 |
4 | const HeatsSelection = ({
5 | selectedFlag,
6 | selectedHeatOption,
7 | setSelectedHeatOption,
8 | }) => {
9 | return (
10 |
11 | setSelectedHeatOption(e)}
14 | colorScheme="gray"
15 | >
16 |
17 | heat.type === "ENVIRONMENT"
20 | )}
21 | value="ENVIRONMENT"
22 | >
23 | Environment
24 |
25 | heat.type === "USER_INCLUDE"
28 | )}
29 | value="USER_INCLUDE"
30 | >
31 | Include users with ids
32 |
33 | heat.type === "USER_EXCLUDE"
36 | )}
37 | value="USER_EXCLUDE"
38 | >
39 | Exclude users with ids
40 |
41 | Custom heat
42 |
43 |
44 |
45 | );
46 | };
47 |
48 | export default HeatsSelection;
49 |
--------------------------------------------------------------------------------
/components/projects/details-section/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {
3 | Box,
4 | HStack,
5 | Divider,
6 | Button,
7 | Switch,
8 | IconButton,
9 | FormControl,
10 | FormLabel,
11 | Tooltip,
12 | Input,
13 | Textarea,
14 | Alert,
15 | AlertIcon,
16 | Fade,
17 | useColorModeValue,
18 | Spinner,
19 | Heading,
20 | Text,
21 | Tabs,
22 | Tab,
23 | TabPanel,
24 | TabPanels,
25 | Modal,
26 | ModalOverlay,
27 | ModalContent,
28 | ModalHeader,
29 | ModalFooter,
30 | ModalBody,
31 | ModalCloseButton,
32 | Icon,
33 | Flex,
34 | SimpleGrid,
35 | TabList,
36 | useDisclosure,
37 | Select,
38 | VStack,
39 | } from "@chakra-ui/react";
40 |
41 | import { useFlagMutation } from "hooks";
42 |
43 | import { useRouter } from "next/dist/client/router";
44 | import { ArrowBackIcon, EditIcon, SettingsIcon } from "@chakra-ui/icons";
45 | import { truncate } from "lodash";
46 |
47 | import { HiArchive } from "react-icons/hi";
48 | import { FaTemperatureHigh } from "react-icons/fa";
49 | import { useMutation, useQueryClient } from "react-query";
50 | import { useAppUrl } from "hooks/useAppUrl";
51 | import HeatRenderer from "./heat-renderer";
52 | import HeatsSelection from "./heats-selection";
53 | import { useState } from "react";
54 | import axios from "axios";
55 | import { Strategy } from "@prisma/client";
56 | import AuditLog from "components/audit-log";
57 |
58 | const DetailsSection = ({
59 | selectedFlag,
60 | project,
61 | setName,
62 | isEditingFlag,
63 | setEditingFlag,
64 | setSelectedFlag,
65 | usedFlags,
66 | name,
67 | description,
68 | setDescription,
69 | }) => {
70 | const router = useRouter();
71 | const appUrl = useAppUrl();
72 | const { isOpen, onOpen, onClose } = useDisclosure();
73 | const [selectedHeatOption, setSelectedHeatOption] = useState<
74 | "ENVIRONMENT" | "USER_INCLUDE" | "USER_EXCLUDE" | "CUSTOM"
75 | >();
76 | const [customHeatStrategy, setCustomHeatStrategy] = useState("IN");
77 | const [customHeatProperty, setCustomHeatProperty] = useState("");
78 | const [customHeatName, setCustomHeatName] = useState("");
79 | const queryClient = useQueryClient();
80 | const flagMutation = useFlagMutation();
81 | const borderColor = useColorModeValue("gray.200", "gray.700");
82 | const textColor = useColorModeValue("gray.600", "gray.300");
83 | const descriptionTextColor = useColorModeValue("gray.400", "gray.500");
84 |
85 | const heatParamsFactory = () => {
86 | switch (selectedHeatOption) {
87 | case "ENVIRONMENT":
88 | return {
89 | type: selectedHeatOption,
90 | property: "environment",
91 | strategy: "IN",
92 | };
93 | case "USER_INCLUDE":
94 | return {
95 | type: selectedHeatOption,
96 | property: "user",
97 | strategy: "IN",
98 | };
99 | case "USER_EXCLUDE":
100 | return {
101 | type: selectedHeatOption,
102 | property: "user",
103 | strategy: "NOT_IN",
104 | };
105 | case "CUSTOM":
106 | return {
107 | type: selectedHeatOption,
108 | property: customHeatProperty,
109 | strategy: customHeatStrategy,
110 | };
111 | default:
112 | break;
113 | }
114 | };
115 |
116 | const createHeatMutation = useMutation(
117 | async () => {
118 | const params = heatParamsFactory();
119 | const result = await axios.post(
120 | `${appUrl}/api/heat/create?flagId=${selectedFlag.id}`,
121 | {
122 | ...params,
123 | name: customHeatName,
124 | }
125 | );
126 |
127 | return result;
128 | },
129 | {
130 | onSuccess: async () => {
131 | await queryClient.refetchQueries(["projects"]);
132 | },
133 | }
134 | );
135 |
136 | return (
137 | <>
138 |
139 | {selectedFlag ? (
140 |
141 |
142 |
158 | {selectedFlag.name}
159 | {selectedFlag.isArchived && (
160 |
161 |
162 |
169 |
170 |
171 | )}
172 |
173 |
174 |
175 | Main
176 | Heats
177 | History
178 | Settings
179 |
180 |
181 |
182 |
183 | {isEditingFlag ? (
184 | setName(e.target.value)}
186 | value={name}
187 | size="sm"
188 | />
189 | ) : (
190 | {selectedFlag.name}
191 | )}
192 | {isEditingFlag ? (
193 |
205 | ) : (
206 | setEditingFlag(!isEditingFlag)}
208 | size="sm"
209 | aria-label="Edit"
210 | icon={}
211 | />
212 | )}
213 |
214 | {isEditingFlag ? (
215 |
255 |
256 |
257 | Heats
258 | }
261 | size="sm"
262 | >
263 | Add heat
264 |
265 |
266 |
267 | Heats are additional layer of configuration to your feature
268 | flags. Want some flags to be turned on only on production or
269 | for particular set of users. Add some heat!
270 |
271 | {selectedFlag.heats.map((heat) => {
272 | return (
273 |
274 |
275 |
276 | );
277 | })}
278 |
279 |
280 |
281 |
282 |
283 |
284 |
285 | Archive flag
286 |
287 |
289 | flagMutation.mutate({
290 | id: selectedFlag.id,
291 | toggleArchive: true,
292 | })
293 | }
294 | isChecked={selectedFlag.isArchived}
295 | colorScheme="green"
296 | size="md"
297 | />
298 | {flagMutation.isLoading && (
299 |
300 | )}
301 |
302 |
303 |
304 | Archiving the flag will automatically turn it off for all
305 | the projects where it is used.
306 |
307 |
308 |
309 |
310 |
311 | ) : (
312 |
313 | {usedFlags?.map((flag) => {
314 | return (
315 |
323 |
324 | setSelectedFlag(flag)}
326 | size="xs"
327 | aria-label="Settings"
328 | icon={}
329 | />
330 |
331 |
338 | setSelectedFlag(flag)}
340 | px={1}
341 | color={textColor}
342 | _hover={{
343 | textDecoration: "underline",
344 | cursor: "pointer",
345 | }}
346 | >
347 | {truncate(flag.name, { length: 25 })}
348 |
349 |
350 |
351 | {flag.isArchived ? (
352 |
359 | ) : (
360 |
361 |
363 | flagMutation.mutate({
364 | id: flag.id,
365 | toggleActive: true,
366 | })
367 | }
368 | isChecked={!flag.isArchived && flag.isActive}
369 | colorScheme="green"
370 | size="md"
371 | />
372 |
373 | )}
374 |
375 | );
376 | })}
377 |
378 | )}
379 |
380 |
386 |
387 |
388 | Add heat to the flag
389 |
390 |
391 |
396 | {selectedHeatOption === "CUSTOM" && (
397 |
398 | setCustomHeatName(e.target.value)}
402 | />
403 | setCustomHeatProperty(e.target.value)}
407 | />
408 |
418 |
419 | )}
420 |
421 |
422 |
425 |
437 |
438 |
439 |
440 | >
441 | );
442 | };
443 |
444 | export default DetailsSection;
445 |
--------------------------------------------------------------------------------
/components/projects/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { Breadcrumb, BreadcrumbItem, BreadcrumbLink } from "@chakra-ui/react";
3 | import Link from "next/link";
4 | import { FeatureFlag, Project } from "@prisma/client";
5 |
6 | const Breadcrumbs = ({
7 | project,
8 | selectedFlag,
9 | }: {
10 | project: Project;
11 | selectedFlag: FeatureFlag;
12 | }) => {
13 | return (
14 | <>
15 | {" "}
16 |
17 |
18 |
19 | Projects
20 |
21 |
22 |
23 |
28 | {project?.name}
29 |
30 |
31 | {selectedFlag && (
32 |
33 | {selectedFlag?.name}
34 |
35 | )}
36 |
37 | >
38 | );
39 | };
40 |
41 | export default Breadcrumbs;
42 |
--------------------------------------------------------------------------------
/components/projects/project-section/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import {
3 | Box,
4 | HStack,
5 | Heading,
6 | VStack,
7 | Button,
8 | IconButton,
9 | Tooltip,
10 | Fade,
11 | Icon,
12 | } from "@chakra-ui/react";
13 | import { HiArchive } from "react-icons/hi";
14 | import { FaFire } from "react-icons/fa";
15 | import BoringAvatar from "boring-avatars";
16 | import { FeatureFlag, Project } from "@prisma/client";
17 | import { useRouter } from "next/dist/client/router";
18 | import { SettingsIcon } from "@chakra-ui/icons";
19 | import { useMutation, useQueryClient } from "react-query";
20 | import { useAppUrl } from "hooks/useAppUrl";
21 |
22 | const ProjectSection = ({
23 | project,
24 | setSelectedFlag,
25 | }: {
26 | project: Project & { featureFlags: ReadonlyArray };
27 | setSelectedFlag: (data: FeatureFlag | undefined) => void;
28 | }) => {
29 | const router = useRouter();
30 | const appUrl = useAppUrl();
31 | const queryClient = useQueryClient();
32 |
33 | const createFlagMutation = useMutation(
34 | async () => {
35 | const result = await fetch(
36 | `${appUrl}/api/flag/create?projectId=${project.id}`
37 | );
38 | const json = await result.json();
39 | return json;
40 | },
41 | {
42 | onSuccess: async (data: FeatureFlag | undefined) => {
43 | setSelectedFlag(data);
44 | await queryClient.refetchQueries(["projects"]);
45 | },
46 | }
47 | );
48 |
49 | return (
50 |
51 |
58 |
59 |
60 | {project.name}
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 | {
72 | project.featureFlags.filter(
73 | (flag) => !flag.isArchived && flag.isActive
74 | ).length
75 | }
76 |
77 |
78 |
79 |
86 |
88 | router.push(
89 | {
90 | pathname: router.pathname,
91 | query: { settings: true },
92 | },
93 | `${project.id}?settings=true`,
94 | { shallow: true }
95 | )
96 | }
97 | aria-label="Settings"
98 | icon={}
99 | />
100 |
101 |
102 |
103 |
104 | );
105 | };
106 |
107 | export default ProjectSection;
108 |
--------------------------------------------------------------------------------
/components/toggle-button-group/index.tsx:
--------------------------------------------------------------------------------
1 | import { ButtonGroup, ButtonGroupProps, useRadioGroup } from "@chakra-ui/react";
2 | import * as React from "react";
3 | import { ToggleButtonProps } from "components/toggle-button";
4 |
5 | interface ToggleButtonGroupProps extends Omit {
6 | name?: string;
7 | value: T;
8 | defaultValue?: string;
9 | onChange?: (value: T) => void;
10 | }
11 |
12 | export const ToggleButtonGroup = (
13 | props: ToggleButtonGroupProps
14 | ) => {
15 | const { children, name, defaultValue, value, onChange, ...rest } = props;
16 | const { getRootProps, getRadioProps } = useRadioGroup({
17 | name,
18 | defaultValue,
19 | value,
20 | onChange,
21 | });
22 |
23 | const buttons = React.useMemo(
24 | () =>
25 | React.Children.toArray(children)
26 | .filter>(React.isValidElement)
27 | .map((button, index, array) => {
28 | const isFirstItem = index === 0;
29 | const isLastItem = array.length === index + 1;
30 |
31 | const styleProps = Object.assign({
32 | ...(isFirstItem && !isLastItem ? { borderRightRadius: 0 } : {}),
33 | ...(!isFirstItem && isLastItem ? { borderLeftRadius: 0 } : {}),
34 | ...(!isFirstItem && !isLastItem ? { borderRadius: 0 } : {}),
35 | ...(!isLastItem ? { mr: "-px" } : {}),
36 | });
37 |
38 | return React.cloneElement(button, {
39 | ...styleProps,
40 | radioProps: getRadioProps({
41 | value: button.props.value,
42 | disabled: props.isDisabled || button.props.isDisabled,
43 | }),
44 | });
45 | }),
46 | [children, getRadioProps, props.isDisabled]
47 | );
48 | return {buttons};
49 | };
50 |
--------------------------------------------------------------------------------
/components/toggle-button/index.tsx:
--------------------------------------------------------------------------------
1 | import {
2 | Box,
3 | IconButton,
4 | IconButtonProps,
5 | useColorModeValue,
6 | useId,
7 | useRadio,
8 | UseRadioProps,
9 | } from "@chakra-ui/react";
10 | import * as React from "react";
11 |
12 | export interface ToggleButtonProps extends IconButtonProps {
13 | value: string;
14 | radioProps?: UseRadioProps;
15 | }
16 |
17 | export const ToggleButton = (props: ToggleButtonProps) => {
18 | const { radioProps, ...rest } = props;
19 | const { getInputProps, getCheckboxProps, getLabelProps } = useRadio(
20 | radioProps
21 | );
22 | const id = useId(undefined, "toggle-button");
23 |
24 | const inputProps = getInputProps();
25 | const checkboxProps = getCheckboxProps();
26 | const labelProps = getLabelProps();
27 |
28 | return (
29 |
30 |
31 |
42 |
43 | );
44 | };
45 |
--------------------------------------------------------------------------------
/context/flags-context.tsx:
--------------------------------------------------------------------------------
1 | import { FeatureFlag } from "@prisma/client";
2 | import * as React from "react";
3 | import { useState } from "react";
4 | import { useEffect } from "react";
5 |
6 | const FlagsContext = React.createContext({});
7 |
8 | function FlagsProvider({ children, projectId }) {
9 | if (projectId === undefined) {
10 | throw new Error("FlagsProvider expects project id");
11 | }
12 | const [data, setData] = useState([]);
13 |
14 | useEffect(() => {
15 | fetch(`https://flags.stackonfire.dev/api/flags/${projectId}`)
16 | .then((res) => res.json())
17 | .then((data) => setData(data));
18 | }, [projectId]);
19 |
20 | return {children};
21 | }
22 |
23 | function useFlags() {
24 | const context = React.useContext(FlagsContext) as ReadonlyArray;
25 | if (context === undefined) {
26 | throw new Error("useFlags must be used within a FlagsProvider");
27 | }
28 | return context;
29 | }
30 |
31 | export { FlagsProvider, useFlags };
32 |
--------------------------------------------------------------------------------
/cypress.env.json:
--------------------------------------------------------------------------------
1 | {
2 | "GITHUB_USER": "test@stackonfire.dev",
3 | "GITHUB_PASSWORD": "cmk9CTF8fdn7gvr-qyv",
4 | "COOKIE_NAME": "next-auth.session-token",
5 | "SITE_NAME": "http://localhost:3000"
6 | }
7 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3.9"
2 | services:
3 | pg:
4 | image: postgres:13
5 | restart: "always"
6 | environment:
7 | POSTGRES_USER: postgres
8 | POSTGRES_PASSWORD: secret
9 | POSTGRES_DB: prisma
10 | ports:
11 | - 4224:5432
12 | volumes:
13 | - ./db:/var/lib/postgresql/data
14 | healthcheck:
15 | test: ["CMD-SHELL", "pg_isready -U postgres"]
16 | interval: 5s
17 | timeout: 5s
18 | retries: 5
19 | fire-flags:
20 | build: .
21 | environment:
22 | DATABASE_URL: postgresql://postgres:secret@pg:5432/prisma
23 | ports:
24 | - 3000:3000
25 | depends_on:
26 | pg:
27 | condition: service_healthy
28 |
--------------------------------------------------------------------------------
/hooks/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./useProject";
2 | export * from "./useProjects";
3 | export * from "./useFlagMutation";
4 | export * from "./useHeatMutation";
5 | export * from "./useProjectMutation";
6 |
--------------------------------------------------------------------------------
/hooks/useAppUrl/index.ts:
--------------------------------------------------------------------------------
1 | const getAppUrl = () =>
2 | (process.env.NODE_ENV === "development" && "http://localhost:3000") ||
3 | (process.env.NODE_ENV === "production" && "https://flags.stackonfire.dev") ||
4 | "http://localhost:3000";
5 |
6 | const useAppUrl = () => {
7 | return getAppUrl();
8 | };
9 |
10 | export { useAppUrl, getAppUrl };
11 |
--------------------------------------------------------------------------------
/hooks/useFlagMutation/index.ts:
--------------------------------------------------------------------------------
1 | import { useAppUrl } from "hooks/useAppUrl";
2 | import axios from "axios";
3 | import { useMutation, useQueryClient } from "react-query";
4 | import { FeatureFlag } from "@prisma/client";
5 |
6 | type Variables = {
7 | id: FeatureFlag["id"];
8 | toggleActive?: boolean;
9 | toggleArchive?: boolean;
10 | name?: string;
11 | description?: string;
12 | };
13 |
14 | const useFlagMutation = () => {
15 | const queryClient = useQueryClient();
16 | const appUrl = useAppUrl();
17 | return useMutation(
18 | async (variables: Variables) => {
19 | const response = await axios.post(
20 | `${appUrl}/api/flag/update?id=${variables.id}`,
21 | {
22 | toggleActive: variables.toggleActive,
23 | toggleArchive: variables.toggleArchive,
24 | name: variables.name,
25 | description: variables.description,
26 | }
27 | );
28 | return response.data;
29 | },
30 | {
31 | onSuccess: async () => {
32 | await queryClient.refetchQueries(["projects"]);
33 | },
34 | }
35 | );
36 | };
37 |
38 | export { useFlagMutation };
39 |
--------------------------------------------------------------------------------
/hooks/useHeatMutation/index.ts:
--------------------------------------------------------------------------------
1 | import { useAppUrl } from "hooks/useAppUrl";
2 | import axios from "axios";
3 | import { useMutation, useQueryClient } from "react-query";
4 | import { Heat } from "@prisma/client";
5 |
6 | type Variables = {
7 | id: Heat["id"];
8 | values: string[];
9 | deleteValues?: boolean;
10 | };
11 |
12 | const useHeatMutation = () => {
13 | const queryClient = useQueryClient();
14 | const appUrl = useAppUrl();
15 | return useMutation(
16 | async (variables: Variables) => {
17 | const response = await axios.post(
18 | `${appUrl}/api/heat/update?id=${variables.id}`,
19 | {
20 | payload: {
21 | values: variables.values,
22 | deleteValues: variables.deleteValues,
23 | },
24 | }
25 | );
26 | return response.data;
27 | },
28 | {
29 | onSuccess: async () => {
30 | await queryClient.refetchQueries(["projects"]);
31 | },
32 | }
33 | );
34 | };
35 |
36 | export { useHeatMutation };
37 |
--------------------------------------------------------------------------------
/hooks/useLogs/index.ts:
--------------------------------------------------------------------------------
1 | import { getAppUrl } from "hooks/useAppUrl";
2 | import { useQuery } from "react-query";
3 |
4 | const fetchLogs = async (id) => {
5 | const appUrl = getAppUrl();
6 | const response = await fetch(`${appUrl}/api/logs/${id}`);
7 | const json = await response.json();
8 |
9 | return json;
10 | };
11 |
12 | const useLogs = ({ id }) => {
13 | return useQuery(["projects", id], () => fetchLogs(id));
14 | };
15 |
16 | export { useLogs, fetchLogs };
17 |
--------------------------------------------------------------------------------
/hooks/useProject/index.ts:
--------------------------------------------------------------------------------
1 | import { getAppUrl } from "hooks/useAppUrl";
2 | import { useQuery } from "react-query";
3 |
4 | const fetchProject = async (id) => {
5 | const appUrl = getAppUrl();
6 | const response = await fetch(`${appUrl}/api/project/${id}`);
7 | const json = await response.json();
8 |
9 | return json;
10 | };
11 |
12 | const useProject = ({ id }) => {
13 | return useQuery(["projects", id], () => fetchProject(id));
14 | };
15 |
16 | export { useProject, fetchProject };
17 |
--------------------------------------------------------------------------------
/hooks/useProjectMutation/index.ts:
--------------------------------------------------------------------------------
1 | import { useAppUrl } from "hooks/useAppUrl";
2 | import axios from "axios";
3 | import { useMutation, useQueryClient } from "react-query";
4 | import { Project } from "@prisma/client";
5 |
6 | type Variables = {
7 | id: Project["id"];
8 | toggleActive?: boolean;
9 | toggleArchive?: boolean;
10 | name?: string;
11 | description?: string;
12 | };
13 |
14 | const useProjectMutation = () => {
15 | const queryClient = useQueryClient();
16 | const appUrl = useAppUrl();
17 | return useMutation(
18 | async (variables: Variables) => {
19 | const response = await axios.post(
20 | `${appUrl}/api/project/update?id=${variables.id}`,
21 | {
22 | toggleArchive: variables.toggleArchive,
23 | name: variables.name,
24 | description: variables.description,
25 | }
26 | );
27 | return response.data;
28 | },
29 | {
30 | onSuccess: async () => {
31 | await queryClient.refetchQueries(["projects"]);
32 | },
33 | }
34 | );
35 | };
36 |
37 | export { useProjectMutation };
38 |
--------------------------------------------------------------------------------
/hooks/useProjects/index.ts:
--------------------------------------------------------------------------------
1 | import { useAppUrl } from "hooks/useAppUrl";
2 |
3 | import { useQuery } from "react-query";
4 |
5 | const fetchProjects = async (appUrl) => {
6 | const response = await fetch(`${appUrl}/api/projects`);
7 | const json = await response.json();
8 |
9 | return json;
10 | };
11 |
12 | const useProjects = ({ skip }: { skip: boolean }) => {
13 | const appUrl = useAppUrl();
14 | return useQuery(["projects"], () => fetchProjects(appUrl), { enabled: skip });
15 | };
16 |
17 | export { useProjects, fetchProjects };
18 |
--------------------------------------------------------------------------------
/img/app-demo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/stack-on-fire/fire-flags/95d17dcaa63c05606a641c25cb14a7b0f8d4801c/img/app-demo.png
--------------------------------------------------------------------------------
/lib/pino.ts:
--------------------------------------------------------------------------------
1 | import pino from "pino";
2 |
3 | export const logger = pino({ level: process.env.LOG_LEVEL });
4 |
--------------------------------------------------------------------------------
/lib/prisma.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from "@prisma/client";
2 |
3 | let prisma: PrismaClient;
4 |
5 | if (process.env.NODE_ENV === "production") {
6 | prisma = new PrismaClient();
7 | } else {
8 | if (!global.prisma) {
9 | global.prisma = new PrismaClient();
10 | }
11 | prisma = global.prisma;
12 | }
13 | export default prisma;
14 |
--------------------------------------------------------------------------------
/mocks/browser.ts:
--------------------------------------------------------------------------------
1 | import { setupWorker } from "msw";
2 | import { handlers } from "mocks/handlers";
3 |
4 | export const worker = setupWorker(...handlers);
5 |
--------------------------------------------------------------------------------
/mocks/handlers.ts:
--------------------------------------------------------------------------------
1 | import { rest } from "msw";
2 |
3 | export const sessionMock = {
4 | expires: "1",
5 | user: {
6 | id: 1,
7 | email: "alpha@me.com",
8 | name: "Alpha",
9 | image: null,
10 | createdAt: "2021-06-30T14:39:05.904Z",
11 | },
12 | };
13 | const flagMock = [
14 | {
15 | id: "123",
16 | name: "project link",
17 | isActive: true,
18 | },
19 | ];
20 |
21 | export const handlers = [
22 | rest.get("/api/auth/session", (req, res, ctx) => {
23 | return res(ctx.json(sessionMock));
24 | }),
25 | rest.get(
26 | "https://flags.stackonfire.dev/api/flags/cksm0s3kg000412l2licbbh8s",
27 | (req, res, ctx) => {
28 | return res(ctx.json(flagMock));
29 | }
30 | ),
31 | ];
32 |
--------------------------------------------------------------------------------
/mocks/index.ts:
--------------------------------------------------------------------------------
1 | if (typeof window === "undefined") {
2 | const { server } = require("mocks/server");
3 | server.listen({ onUnhandledRequest: "bypass" });
4 | } else {
5 | const { worker } = require("mocks/browser");
6 | worker.start({ quiet: true, onUnhandledRequest: "bypass" });
7 | }
8 |
9 | export {};
10 |
--------------------------------------------------------------------------------
/mocks/server.ts:
--------------------------------------------------------------------------------
1 | import { setupServer } from "msw/node";
2 | import { handlers } from "mocks/handlers";
3 |
4 | export const server = setupServer(...handlers);
5 |
--------------------------------------------------------------------------------
/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 | ///
4 |
5 | // NOTE: This file should not be edited
6 | // see https://nextjs.org/docs/basic-features/typescript for more information.
7 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "stackonfire",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev | pino-pretty --config pino.json",
7 | "lint": "next lint",
8 | "build": "next build",
9 | "start": "next start",
10 | "vercel-build": "prisma generate && prisma migrate deploy && next build",
11 | "prisma:generate": "prisma generate",
12 | "ts-node": "ts-node --compiler-options \"{\\\"module\\\":\\\"commonjs\\\"}\""
13 | },
14 | "dependencies": {
15 | "@chakra-ui/icons": "^1.0.13",
16 | "@chakra-ui/react": "^1.6.2",
17 | "@emotion/react": "^11",
18 | "@emotion/styled": "^11",
19 | "@prisma/client": "2.23.0",
20 | "@svgr/webpack": "^5.5.0",
21 | "axios": "^0.21.1",
22 | "boring-avatars": "^1.5.8",
23 | "date-fns": "^2.23.0",
24 | "framer-motion": "^4",
25 | "fuse.js": "^6.4.6",
26 | "lodash": "^4.17.21",
27 | "msw": "^0.35.0",
28 | "next": "^11.1.0",
29 | "next-auth": "^3.23.3",
30 | "next-images": "^1.7.0",
31 | "nextjs-progressbar": "^0.0.11",
32 | "pino": "6.13.2",
33 | "pino-pretty": "6.0.0",
34 | "random-word-slugs": "^0.0.4",
35 | "react": "^17.0.2",
36 | "react-dom": "^17.0.2",
37 | "react-hot-toast": "^1.0.2",
38 | "react-icons": "^4.2.0",
39 | "react-query": "^3.19.2"
40 | },
41 | "devDependencies": {
42 | "@types/node": "^16.4.1",
43 | "@types/react": "^17.0.6",
44 | "@typescript-eslint/eslint-plugin": "^4.29.2",
45 | "eslint": "7.32.0",
46 | "eslint-config-next": "11.1.0",
47 | "prisma": "2.23.0",
48 | "ts-node": "^10.1.0",
49 | "typescript": "^4.3.5"
50 | },
51 | "msw": {
52 | "workerDirectory": "public"
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/pages/_app.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import NextNprogress from "nextjs-progressbar";
3 | import type { AppProps } from "next/app";
4 | import { ChakraProvider } from "@chakra-ui/react";
5 | import { QueryClient, QueryClientProvider } from "react-query";
6 | import { Hydrate } from "react-query/hydration";
7 | import { Toaster } from "react-hot-toast";
8 | import { Provider } from "next-auth/client";
9 | import { ReactQueryDevtools } from "react-query/devtools";
10 | import { FlagsProvider } from "context/flags-context";
11 |
12 | if (process.env.NODE_ENV === "development") {
13 | require("mocks");
14 | }
15 |
16 | function MyApp({ Component, pageProps }: AppProps) {
17 | const [queryClient] = React.useState(() => new QueryClient());
18 |
19 | return (
20 |
21 |
22 |
23 |
24 |
25 |
26 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 | );
44 | }
45 | export default MyApp;
46 |
--------------------------------------------------------------------------------
/pages/_document.tsx:
--------------------------------------------------------------------------------
1 | import Document, { Html, Head, Main, NextScript } from "next/document";
2 | import { ColorModeScript, ThemeConfig } from "@chakra-ui/react";
3 |
4 | import { extendTheme } from "@chakra-ui/react";
5 | const config = {
6 | initialColorMode: "dark",
7 | useSystemColorMode: false,
8 | };
9 | const theme = extendTheme({ config } as ThemeConfig);
10 |
11 | class MyDocument extends Document {
12 | static async getInitialProps(ctx) {
13 | const initialProps = await Document.getInitialProps(ctx);
14 | return { ...initialProps };
15 | }
16 |
17 | render() {
18 | return (
19 |
20 |
21 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 | );
33 | }
34 | }
35 |
36 | export default MyDocument;
37 |
--------------------------------------------------------------------------------
/pages/api/auth/[...nextauth].ts:
--------------------------------------------------------------------------------
1 | import { NextApiHandler } from "next";
2 | import NextAuth from "next-auth";
3 | import Providers from "next-auth/providers";
4 | import Adapters from "next-auth/adapters";
5 |
6 | import { PrismaClient } from "@prisma/client";
7 |
8 | const prisma = new PrismaClient();
9 |
10 | const authHandler: NextApiHandler = (req, res) => NextAuth(req, res, options);
11 | export default authHandler;
12 |
13 | const options = {
14 | providers: [
15 | Providers.Email({
16 | server: {
17 | host: process.env.SMTP_HOST,
18 | port: Number(process.env.SMTP_PORT),
19 | auth: {
20 | user: process.env.SMTP_USER,
21 | pass: process.env.SMTP_PASSWORD,
22 | },
23 | },
24 | from: process.env.SMTP_FROM,
25 | }),
26 | Providers.GitHub({
27 | clientId: process.env.GITHUB_CLIENT_ID,
28 | clientSecret: process.env.GITHUB_CLIENT_SECRET,
29 | }),
30 | Providers.Twitter({
31 | clientId: process.env.TWITTER_CONSUMER_KEY,
32 | clientSecret: process.env.TWITTER_CONSUMER_SECRET,
33 | }),
34 | ],
35 | adapter: Adapters.Prisma.Adapter({
36 | prisma,
37 | }),
38 |
39 | secret: process.env.SECRET,
40 | callbacks: {
41 | session: async (session, user) => {
42 | session.user.id = user.id;
43 | session.user.createdAt = user.createdAt;
44 | session.user.name = user.name;
45 | return Promise.resolve(session);
46 | },
47 | },
48 | };
49 |
--------------------------------------------------------------------------------
/pages/api/flag/create/index.ts:
--------------------------------------------------------------------------------
1 | import { getSession } from "next-auth/client";
2 | import { Prisma } from "@prisma/client";
3 | import prisma from "lib/prisma";
4 |
5 | import { generateSlug } from "random-word-slugs";
6 | import { sessionMock } from "mocks/handlers";
7 |
8 | export default async function handle(req, res) {
9 | const session =
10 | (await getSession({ req })) ||
11 | (process.env.NODE_ENV === "development" && sessionMock);
12 |
13 | const { projectId } = req.query;
14 | const name = generateSlug(3, {
15 | format: "kebab",
16 | partsOfSpeech: ["adjective", "adjective", "noun"],
17 | categories: {
18 | adjective: ["color", "personality"],
19 | noun: ["animals"],
20 | },
21 | });
22 |
23 | const featureFlag = await prisma.featureFlag.create({
24 | data: {
25 | name,
26 | Project: {
27 | connect: {
28 | id: projectId,
29 | },
30 | },
31 | },
32 | });
33 |
34 | console.log(session.user.id);
35 |
36 | await prisma.auditLog.create({
37 | data: {
38 | flagId: featureFlag.id,
39 | userId: session.user.id,
40 | type: "FLAG_CREATE",
41 | after: featureFlag as unknown as Prisma.JsonObject,
42 | },
43 | });
44 |
45 | res.json(featureFlag);
46 | }
47 |
--------------------------------------------------------------------------------
/pages/api/flag/update/index.ts:
--------------------------------------------------------------------------------
1 | import { getSession } from "next-auth/client";
2 | import { Prisma } from "@prisma/client";
3 | import prisma from "lib/prisma";
4 | import { sessionMock } from "mocks/handlers";
5 | import { logger } from "lib/pino";
6 |
7 | export default async function handle(req, res) {
8 | const { id } = req.query;
9 |
10 | logger.info(`Updating flag with id ${id}`);
11 |
12 | const session =
13 | (await getSession({ req })) ||
14 | (process.env.NODE_ENV === "development" && sessionMock);
15 |
16 | const { name, description, toggleActive, toggleArchive } = req.body;
17 |
18 | logger.debug("starting featureFlag.find");
19 | const currentFeatureFlag = await prisma.featureFlag.findUnique({
20 | where: { id },
21 | });
22 | const toggleActiveFromArchiveOperation = toggleArchive;
23 |
24 | logger.debug("starting featureFlag.update");
25 | const featureFlag = await prisma.featureFlag.update({
26 | where: {
27 | id,
28 | },
29 | data: {
30 | name,
31 | description,
32 | isActive:
33 | toggleActive || toggleActiveFromArchiveOperation
34 | ? !currentFeatureFlag.isActive
35 | : currentFeatureFlag.isActive,
36 | isArchived: toggleArchive
37 | ? !currentFeatureFlag.isArchived
38 | : currentFeatureFlag.isArchived,
39 | },
40 | });
41 |
42 | logger.debug("starting auditLog.create");
43 | await prisma.auditLog.create({
44 | data: {
45 | flagId: featureFlag.id,
46 | userId: session.user.id,
47 | type: "FLAG_UPDATE",
48 | before: currentFeatureFlag as unknown as Prisma.JsonObject,
49 | after: featureFlag as unknown as Prisma.JsonObject,
50 | },
51 | });
52 |
53 | res.json(featureFlag);
54 | }
55 |
--------------------------------------------------------------------------------
/pages/api/flags/[projectId].ts:
--------------------------------------------------------------------------------
1 | import { Heat } from "@prisma/client";
2 | import prisma from "lib/prisma";
3 |
4 | const allowCors = (fn) => async (req, res) => {
5 | res.setHeader("Access-Control-Allow-Credentials", true);
6 | res.setHeader("Access-Control-Allow-Origin", "*");
7 | res.setHeader(
8 | "Access-Control-Allow-Methods",
9 | "GET,OPTIONS,PATCH,DELETE,POST,PUT"
10 | );
11 | res.setHeader(
12 | "Access-Control-Allow-Headers",
13 | "X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version"
14 | );
15 | if (req.method === "OPTIONS") {
16 | res.status(200).end();
17 | return;
18 | }
19 | return await fn(req, res);
20 | };
21 |
22 | function isHeatApplicable(customHeat: Heat, config: Map) {
23 | const value = config[customHeat.property];
24 | if (value) {
25 | switch (customHeat.strategy) {
26 | case "IN":
27 | return customHeat.values.includes(value);
28 | case "NOT_IN":
29 | return !customHeat.values.includes(value);
30 | default:
31 | return false;
32 | }
33 | }
34 | }
35 |
36 | const handle = async (req, res) => {
37 | const { projectId, ...config } = req.query;
38 |
39 | const flags = await prisma.featureFlag.findMany({
40 | where: {
41 | projectId: String(projectId),
42 | },
43 | include: {
44 | heats: true,
45 | },
46 | });
47 |
48 | res.setHeader("Cache-Control", "s-maxage=60, stale-while-revalidate");
49 | res
50 | .status(200)
51 | .json(
52 | flags.filter((flag) =>
53 | flag.heats.every((heat) => isHeatApplicable(heat, config))
54 | )
55 | );
56 | };
57 |
58 | module.exports = allowCors(handle);
59 |
--------------------------------------------------------------------------------
/pages/api/heat/create/index.ts:
--------------------------------------------------------------------------------
1 | import { getSession } from "next-auth/client";
2 | import { Prisma } from "@prisma/client";
3 | import prisma from "lib/prisma";
4 | import { sessionMock } from "mocks/handlers";
5 |
6 | export default async function handle(req, res) {
7 | const { flagId } = req.query;
8 | const { type, strategy, property, name } = req.body;
9 | const session =
10 | (await getSession({ req })) ||
11 | (process.env.NODE_ENV === "development" && sessionMock);
12 |
13 | if (!type) {
14 | return res.status(422);
15 | }
16 |
17 | const flagBefore = await prisma.featureFlag.findUnique({
18 | where: {
19 | id: flagId,
20 | },
21 | });
22 |
23 | const heat = await prisma.heat.create({
24 | data: {
25 | type,
26 | name,
27 | strategy: strategy
28 | ? strategy
29 | : ["ENVIRONMENT", "USER_INCLUDE"].includes(type)
30 | ? "IN"
31 | : "NOT_IN",
32 | property:
33 | property ??
34 | ((type === "ENVIRONMENT" && "environment") ||
35 | (["USER_EXCLUDE", "USER_INCLUDE"].includes(type) && "users")),
36 | FeatureFlag: {
37 | connect: {
38 | id: flagId,
39 | },
40 | },
41 | },
42 | });
43 |
44 | const flagAfter = await prisma.featureFlag.findUnique({
45 | where: {
46 | id: flagId,
47 | },
48 | });
49 |
50 | await prisma.auditLog.create({
51 | data: {
52 | flagId: flagId,
53 | userId: session.user.id,
54 | type: "HEAT_CREATE",
55 | before: flagBefore as unknown as Prisma.JsonObject,
56 | after: flagAfter as unknown as Prisma.JsonObject,
57 | },
58 | });
59 |
60 | res.json(heat);
61 | }
62 |
--------------------------------------------------------------------------------
/pages/api/heat/delete/index.ts:
--------------------------------------------------------------------------------
1 | import { getSession } from "next-auth/client";
2 | import { Prisma } from "@prisma/client";
3 | import prisma from "lib/prisma";
4 | import { sessionMock } from "mocks/handlers";
5 |
6 | export default async function handle(req, res) {
7 | const { id } = req.query;
8 | const session =
9 | (await getSession({ req })) ||
10 | (process.env.NODE_ENV === "development" && sessionMock);
11 |
12 | const currentHeat = await prisma.heat.findUnique({
13 | where: {
14 | id,
15 | },
16 | });
17 |
18 | const flagBefore = await prisma.featureFlag.findUnique({
19 | where: {
20 | id: currentHeat.flagId,
21 | },
22 | });
23 |
24 | const deletedHeat = await prisma.heat.delete({
25 | where: {
26 | id,
27 | },
28 | });
29 |
30 | const flagAfter = await prisma.featureFlag.findUnique({
31 | where: {
32 | id: currentHeat.flagId,
33 | },
34 | });
35 |
36 | await prisma.auditLog.create({
37 | data: {
38 | flagId: currentHeat.flagId,
39 | userId: session.user.id,
40 | type: "HEAT_DELETE",
41 | before: flagBefore as unknown as Prisma.JsonObject,
42 | after: flagAfter as unknown as Prisma.JsonObject,
43 | },
44 | });
45 |
46 | res.json(deletedHeat);
47 | }
48 |
--------------------------------------------------------------------------------
/pages/api/heat/update/index.ts:
--------------------------------------------------------------------------------
1 | import { getSession } from "next-auth/client";
2 | import { Prisma } from "@prisma/client";
3 | import prisma from "lib/prisma";
4 | import { uniq } from "lodash";
5 | import { sessionMock } from "mocks/handlers";
6 |
7 | export default async function handle(req, res) {
8 | const { id } = req.query;
9 | const session =
10 | (await getSession({ req })) ||
11 | (process.env.NODE_ENV === "development" && sessionMock);
12 |
13 | const {
14 | payload: { values, deleteValues },
15 | } = req.body;
16 |
17 | const currentHeat = await prisma.heat.findUnique({
18 | where: {
19 | id,
20 | },
21 | });
22 |
23 | const flagBefore = await prisma.featureFlag.findUnique({
24 | where: {
25 | id: currentHeat.flagId,
26 | },
27 | });
28 |
29 | console.log(flagBefore);
30 |
31 | const valuesFromPayload = values ?? [];
32 |
33 | const valuesToAdd =
34 | deleteValues || ["ENVIRONMENT"].includes(currentHeat.type)
35 | ? values
36 | : uniq([...valuesFromPayload, ...currentHeat.values]);
37 |
38 | const updatedheat = await prisma.heat.update({
39 | where: {
40 | id,
41 | },
42 | data: {
43 | values: valuesToAdd,
44 | },
45 | });
46 |
47 | const flagAfter = await prisma.featureFlag.findUnique({
48 | where: {
49 | id: currentHeat.flagId,
50 | },
51 | });
52 |
53 | await prisma.auditLog.create({
54 | data: {
55 | flagId: currentHeat.flagId,
56 | userId: session.user.id,
57 | type: "HEAT_UPDATE",
58 | before: flagBefore as unknown as Prisma.JsonObject,
59 | after: flagAfter as unknown as Prisma.JsonObject,
60 | },
61 | });
62 |
63 | res.json(updatedheat);
64 | }
65 |
--------------------------------------------------------------------------------
/pages/api/logs/[id].ts:
--------------------------------------------------------------------------------
1 | import prisma from "lib/prisma";
2 |
3 | export default async function handle(req, res) {
4 | const { id } = req.query;
5 |
6 | const logs = await prisma.auditLog.findMany({
7 | where: {
8 | flagId: id,
9 | },
10 | include: {
11 | User: true,
12 | },
13 | orderBy: {
14 | createdAt: "desc",
15 | },
16 | });
17 |
18 | res.json(logs);
19 | }
20 |
--------------------------------------------------------------------------------
/pages/api/project/[projectId].ts:
--------------------------------------------------------------------------------
1 | import prisma from "lib/prisma";
2 |
3 | export default async function handle(req, res) {
4 | const project = await prisma.project.findUnique({
5 | where: {
6 | id: String(req.query.projectId),
7 | },
8 | include: {
9 | featureFlags: {
10 | include: {
11 | heats: {
12 | orderBy: {
13 | createdAt: "asc",
14 | },
15 | },
16 | },
17 | orderBy: {
18 | createdAt: "asc",
19 | },
20 | },
21 | },
22 | });
23 |
24 | res.json(project);
25 | }
26 |
--------------------------------------------------------------------------------
/pages/api/project/create/index.ts:
--------------------------------------------------------------------------------
1 | import prisma from "lib/prisma";
2 | import { sessionMock } from "mocks/handlers";
3 | import { getSession } from "next-auth/client";
4 |
5 | export default async function handle(req, res) {
6 | const session =
7 | (await getSession({ req })) ||
8 | (process.env.NODE_ENV === "development" && sessionMock);
9 |
10 | const { projectName } = req.query;
11 |
12 | const project = await prisma.project.create({
13 | data: {
14 | name: projectName,
15 | userId: String(session.user.id),
16 | },
17 | });
18 | res.json(project);
19 | }
20 |
--------------------------------------------------------------------------------
/pages/api/project/update/index.ts:
--------------------------------------------------------------------------------
1 | import prisma from "lib/prisma";
2 |
3 | export default async function handle(req, res) {
4 | const { id } = req.query;
5 | const { toggleArchive, name } = req.body;
6 |
7 | const currentProject = await prisma.project.findUnique({
8 | where: { id },
9 | });
10 |
11 | const featureFlag = await prisma.project.update({
12 | where: {
13 | id,
14 | },
15 | data: {
16 | name,
17 | isArchived: toggleArchive
18 | ? !currentProject.isArchived
19 | : currentProject.isArchived,
20 | },
21 | });
22 |
23 | res.json(featureFlag);
24 | }
25 |
--------------------------------------------------------------------------------
/pages/api/projects/index.ts:
--------------------------------------------------------------------------------
1 | import prisma from "lib/prisma";
2 | import { sessionMock } from "mocks/handlers";
3 | import { getSession } from "next-auth/client";
4 |
5 | export default async function handle(req, res) {
6 | const session =
7 | (await getSession({ req })) ||
8 | (process.env.NODE_ENV === "development" && sessionMock);
9 |
10 | const projects = await prisma.project.findMany({
11 | where: {
12 | userId: String(session?.user.id),
13 | },
14 | include: {
15 | featureFlags: true,
16 | },
17 | });
18 |
19 | res.json(projects);
20 | }
21 |
--------------------------------------------------------------------------------
/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { Project, FeatureFlag } from "@prisma/client";
3 |
4 | import { Box } from "@chakra-ui/layout";
5 | import { Navbar } from "components";
6 | import {
7 | Button,
8 | Flex,
9 | SimpleGrid,
10 | useDisclosure,
11 | Modal,
12 | ModalOverlay,
13 | ModalContent,
14 | ModalHeader,
15 | ModalFooter,
16 | ModalBody,
17 | ModalCloseButton,
18 | useColorModeValue as mode,
19 | FormControl,
20 | InputGroup,
21 | FormLabel,
22 | Input,
23 | Skeleton,
24 | Center,
25 | OrderedList,
26 | ListItem,
27 | Link,
28 | Heading,
29 | VStack,
30 | } from "@chakra-ui/react";
31 | import { PlusSquareIcon } from "@chakra-ui/icons";
32 | import ProjectCard from "components/project-card";
33 |
34 | import { useProjects } from "hooks";
35 | import { useMutation, useQueryClient } from "react-query";
36 |
37 | import { useAppUrl } from "hooks/useAppUrl";
38 | import { useSession } from "next-auth/client";
39 | import { useRouter } from "next/dist/client/router";
40 |
41 | const Index = () => {
42 | const [session] = useSession();
43 | const router = useRouter();
44 | const { isOpen, onOpen, onClose } = useDisclosure();
45 | const [projectName, setProjectName] = useState("");
46 | const queryClient = useQueryClient();
47 | const appUrl = useAppUrl();
48 |
49 | const {
50 | data: projects,
51 | isLoading,
52 | }: {
53 | data: ReadonlyArray }>;
54 | isLoading: boolean;
55 | } = useProjects({ skip: !!session });
56 |
57 | const createProjectMutation = useMutation(
58 | ({ projectName }: { projectName: string }) =>
59 | fetch(`${appUrl}/api/project/create?projectName=${projectName}`),
60 | {
61 | onSuccess: async () => {
62 | await queryClient.refetchQueries(["projects"]);
63 | },
64 | }
65 | );
66 |
67 | console.log(session);
68 |
69 | return (
70 |
71 |
72 |
73 |
74 | {session && (
75 |
84 |
96 |
97 | )}
98 | {session?.user && isLoading ? (
99 |
100 |
101 |
102 |
103 |
104 | ) : projects?.length > 0 ? (
105 |
106 | {projects?.map((project) => (
107 |
108 | ))}
109 |
110 | ) : (
111 |
112 |
113 | Quick start guide
114 |
115 | Create new project
116 | Create a feature flag
117 | Create some heats (Optional)
118 |
119 |
123 | Use in React
124 | {" "}
125 | or make a call to API.
126 |
127 |
128 |
129 |
130 | )}
131 |
132 |
133 |
134 |
135 | Create a new project
136 |
137 |
138 |
139 |
140 | Enter project name
141 | setProjectName(e.target.value)}
144 | bg={mode("white", "gray.800")}
145 | placeholder="Project name"
146 | />
147 |
148 |
149 |
150 |
151 |
154 |
163 |
164 |
165 |
166 |
167 | );
168 | };
169 |
170 | export default Index;
171 |
--------------------------------------------------------------------------------
/pages/projects/[id].tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from "react";
2 | import { Navbar } from "components";
3 | import { Project as ProjectType, FeatureFlag } from "@prisma/client";
4 | import {
5 | Box,
6 | HStack,
7 | Divider,
8 | Button,
9 | Switch,
10 | IconButton,
11 | FormControl,
12 | FormLabel,
13 | Tooltip,
14 | Input,
15 | Code,
16 | Spinner,
17 | useClipboard,
18 | VStack,
19 | Heading,
20 | Text,
21 | Skeleton,
22 | useMediaQuery,
23 | } from "@chakra-ui/react";
24 | import { QueryClient } from "react-query";
25 | import {
26 | fetchProject,
27 | useFlagMutation,
28 | useProject,
29 | useProjectMutation,
30 | } from "hooks";
31 | import { dehydrate } from "react-query/hydration";
32 | import { useRouter } from "next/dist/client/router";
33 | import { ArrowBackIcon, EditIcon } from "@chakra-ui/icons";
34 | import { useEffect } from "react";
35 | import Fuse from "fuse.js";
36 | import { useAppUrl } from "hooks/useAppUrl";
37 | import FireFeature from "components/fire-feature";
38 | import Breadcrumbs from "components/projects";
39 | import ProjectSection from "components/projects/project-section";
40 | import DetailsSection from "components/projects/details-section";
41 | import { truncate } from "lodash";
42 |
43 | const Project = () => {
44 | const router = useRouter();
45 | const flagMutation = useFlagMutation();
46 | const projectMutation = useProjectMutation();
47 | const [isSmallerThan600px] = useMediaQuery("(max-width: 600px)");
48 |
49 | const appUrl = useAppUrl();
50 |
51 | const {
52 | data: project,
53 | }: {
54 | data: ProjectType & { featureFlags: ReadonlyArray };
55 | } = useProject({ id: router.query.id });
56 | const selectedFlag = project?.featureFlags.find(
57 | (flag) => flag.id === router.query.flag
58 | );
59 |
60 | const [isEditingFlag, setEditingFlag] = useState(false);
61 | const [isEditingProject, setEditingProject] = useState(false);
62 | const [name, setName] = useState("");
63 | const [projectName, setProjectName] = useState("");
64 | const [description, setDescription] = useState("");
65 | const [searchString, setSearchString] = useState("");
66 |
67 | useEffect(() => {
68 | if (selectedFlag) {
69 | setName(selectedFlag.name);
70 | setDescription(selectedFlag.description);
71 | }
72 | }, [project, selectedFlag]);
73 |
74 | useEffect(() => {
75 | setEditingFlag(false);
76 | if (selectedFlag) {
77 | setName(selectedFlag.name);
78 | setDescription(selectedFlag.description);
79 | }
80 | if (project) {
81 | setProjectName(project.name);
82 | }
83 | }, [router.pathname, selectedFlag, project]);
84 |
85 | const setSelectedFlag = (flag: FeatureFlag) =>
86 | router.push(
87 | {
88 | pathname: router.pathname,
89 | query: { flag: flag.id },
90 | },
91 | `${project.id}?flag=${flag.id}`,
92 | { shallow: true }
93 | );
94 |
95 | const options = {
96 | includeScore: true,
97 | keys: ["name"],
98 | };
99 | const fuse = new Fuse(project?.featureFlags, options);
100 | const result = project?.featureFlags ? fuse.search(searchString) : [];
101 | const usedFlags = searchString
102 | ? result.map(({ item }) => item)
103 | : project?.featureFlags;
104 |
105 | const accessUrl = `${appUrl}/api/flags/${project?.id}`;
106 | const { hasCopied, onCopy } = useClipboard(accessUrl);
107 |
108 | return (
109 | <>
110 |
111 |
112 |
113 | {project ? (
114 |
115 | ) : (
116 |
117 |
118 |
119 |
120 |
121 |
122 |
123 | )}
124 |
125 |
126 |
127 | {truncate(accessUrl, { length: isSmallerThan600px ? 25 : 100 })}
128 |
129 |
132 |
133 |
134 |
135 | {router.query.settings === "true" ? (
136 |
137 |
138 |
154 | Project settings
155 |
156 |
157 |
158 | {isEditingProject ? (
159 | setProjectName(e.target.value)}
161 | value={projectName}
162 | size="sm"
163 | />
164 | ) : (
165 | {project?.name}
166 | )}
167 | {isEditingProject ? (
168 |
179 | ) : (
180 | setEditingProject(!isEditingProject)}
182 | size="sm"
183 | aria-label="Edit"
184 | icon={}
185 | />
186 | )}
187 |
188 |
189 |
190 |
191 | Archive project?
192 |
193 |
195 | projectMutation.mutate({
196 | id: project.id,
197 | toggleArchive: true,
198 | })
199 | }
200 | size="md"
201 | colorScheme="green"
202 | isChecked={project?.isArchived}
203 | id="release-toggle"
204 | />
205 | {flagMutation.isLoading && (
206 |
207 | )}
208 |
209 |
210 |
211 | ) : (
212 |
213 | {!selectedFlag && (
214 |
215 | setSearchString(e.target.value)}
218 | placeholder="Search feature flags"
219 | />
220 |
221 | )}
222 |
234 |
235 | )}
236 |
237 | >
238 | );
239 | };
240 |
241 | export default Project;
242 |
243 | export const getServerSideProps = async ({ query }) => {
244 | const projectId = query.id;
245 |
246 | const queryClient = new QueryClient();
247 |
248 | await queryClient.prefetchQuery(["project", projectId], () =>
249 | fetchProject(projectId)
250 | );
251 |
252 | return { props: { dehydratedState: dehydrate(queryClient) } };
253 | };
254 |
--------------------------------------------------------------------------------
/pages/signin.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | import { Login } from "components";
4 |
5 | const SignIn = () => {
6 | return ;
7 | };
8 |
9 | export default SignIn;
10 |
--------------------------------------------------------------------------------
/pino.json:
--------------------------------------------------------------------------------
1 | {
2 | "ignore": "pid,hostname",
3 | "translateTime": "SYS:HH:MM:ss. l"
4 | }
5 |
--------------------------------------------------------------------------------
/prisma/migrations/20210724070207_inital/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE "accounts" (
3 | "id" SERIAL NOT NULL,
4 | "compound_id" TEXT NOT NULL,
5 | "user_id" INTEGER NOT NULL,
6 | "provider_type" TEXT NOT NULL,
7 | "provider_id" TEXT NOT NULL,
8 | "provider_account_id" TEXT NOT NULL,
9 | "refresh_token" TEXT,
10 | "access_token" TEXT,
11 | "access_token_expires" TIMESTAMP(3),
12 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
13 | "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
14 |
15 | PRIMARY KEY ("id")
16 | );
17 |
18 | -- CreateTable
19 | CREATE TABLE "sessions" (
20 | "id" SERIAL NOT NULL,
21 | "user_id" INTEGER NOT NULL,
22 | "expires" TIMESTAMP(3) NOT NULL,
23 | "session_token" TEXT NOT NULL,
24 | "access_token" TEXT NOT NULL,
25 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
26 | "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
27 |
28 | PRIMARY KEY ("id")
29 | );
30 |
31 | -- CreateTable
32 | CREATE TABLE "users" (
33 | "id" SERIAL NOT NULL,
34 | "name" TEXT,
35 | "email" TEXT,
36 | "email_verified" TIMESTAMP(3),
37 | "image" TEXT,
38 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
39 | "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
40 |
41 | PRIMARY KEY ("id")
42 | );
43 |
44 | -- CreateTable
45 | CREATE TABLE "verification_requests" (
46 | "id" SERIAL NOT NULL,
47 | "identifier" TEXT NOT NULL,
48 | "token" TEXT NOT NULL,
49 | "expires" TIMESTAMP(3) NOT NULL,
50 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
51 | "updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
52 |
53 | PRIMARY KEY ("id")
54 | );
55 |
56 | -- CreateIndex
57 | CREATE UNIQUE INDEX "accounts.compound_id_unique" ON "accounts"("compound_id");
58 |
59 | -- CreateIndex
60 | CREATE INDEX "providerAccountId" ON "accounts"("provider_account_id");
61 |
62 | -- CreateIndex
63 | CREATE INDEX "providerId" ON "accounts"("provider_id");
64 |
65 | -- CreateIndex
66 | CREATE INDEX "userId" ON "accounts"("user_id");
67 |
68 | -- CreateIndex
69 | CREATE UNIQUE INDEX "sessions.session_token_unique" ON "sessions"("session_token");
70 |
71 | -- CreateIndex
72 | CREATE UNIQUE INDEX "sessions.access_token_unique" ON "sessions"("access_token");
73 |
74 | -- CreateIndex
75 | CREATE UNIQUE INDEX "users.email_unique" ON "users"("email");
76 |
77 | -- CreateIndex
78 | CREATE UNIQUE INDEX "verification_requests.token_unique" ON "verification_requests"("token");
79 |
--------------------------------------------------------------------------------
/prisma/migrations/20210806140102_add_projects_to_schema/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE "projects" (
3 | "id" TEXT NOT NULL,
4 | "name" TEXT NOT NULL,
5 | "description" TEXT,
6 | "userId" TEXT NOT NULL,
7 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
8 | "updated_at" TIMESTAMP(3) NOT NULL,
9 | "userId_ProjectToUser" INTEGER,
10 |
11 | PRIMARY KEY ("id")
12 | );
13 |
14 | -- CreateTable
15 | CREATE TABLE "FeatureFlag" (
16 | "id" TEXT NOT NULL,
17 | "projectId" TEXT,
18 | "name" TEXT NOT NULL,
19 | "description" TEXT,
20 | "isActive" BOOLEAN NOT NULL DEFAULT false,
21 | "isArchived" BOOLEAN NOT NULL DEFAULT false,
22 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
23 | "updated_at" TIMESTAMP(3) NOT NULL,
24 |
25 | PRIMARY KEY ("id")
26 | );
27 |
28 | -- AddForeignKey
29 | ALTER TABLE "FeatureFlag" ADD FOREIGN KEY ("projectId") REFERENCES "projects"("id") ON DELETE SET NULL ON UPDATE CASCADE;
30 |
31 | -- AddForeignKey
32 | ALTER TABLE "projects" ADD FOREIGN KEY ("userId_ProjectToUser") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
33 |
--------------------------------------------------------------------------------
/prisma/migrations/20210820191030_add_is_archived_field_to_the_project/migration.sql:
--------------------------------------------------------------------------------
1 | -- AlterTable
2 | ALTER TABLE "projects" ADD COLUMN "isArchived" BOOLEAN NOT NULL DEFAULT false;
3 |
--------------------------------------------------------------------------------
/prisma/migrations/20210823191108_add_heats_to_schema/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateEnum
2 | CREATE TYPE "HeatType" AS ENUM ('ENVIRONMENT', 'USER_INCLUDE', 'USER_EXCLUDE');
3 |
4 | -- CreateTable
5 | CREATE TABLE "Heat" (
6 | "id" TEXT NOT NULL,
7 | "flagId" TEXT,
8 | "type" "HeatType" NOT NULL DEFAULT E'ENVIRONMENT',
9 | "environments" TEXT[],
10 | "users" TEXT[],
11 |
12 | PRIMARY KEY ("id")
13 | );
14 |
15 | -- AddForeignKey
16 | ALTER TABLE "Heat" ADD FOREIGN KEY ("flagId") REFERENCES "FeatureFlag"("id") ON DELETE SET NULL ON UPDATE CASCADE;
17 |
--------------------------------------------------------------------------------
/prisma/migrations/20210824170027_add_created_and_updated_columns_to_heats/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - Added the required column `updated_at` to the `Heat` table without a default value. This is not possible if the table is not empty.
5 |
6 | */
7 | -- AlterTable
8 | ALTER TABLE "Heat" ADD COLUMN "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
9 | ADD COLUMN "updated_at" TIMESTAMP(3) NOT NULL;
10 |
--------------------------------------------------------------------------------
/prisma/migrations/20210904201333_add_new_heats_structure/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - You are about to drop the column `environments` on the `Heat` table. All the data in the column will be lost.
5 | - You are about to drop the column `users` on the `Heat` table. All the data in the column will be lost.
6 |
7 | */
8 | -- CreateEnum
9 | CREATE TYPE "Strategy" AS ENUM ('IN', 'NOT_IN');
10 |
11 | -- AlterEnum
12 | ALTER TYPE "HeatType" ADD VALUE 'CUSTOM';
13 |
14 | -- AlterTable
15 | ALTER TABLE "Heat" DROP COLUMN "environments",
16 | DROP COLUMN "users",
17 | ADD COLUMN "name" TEXT,
18 | ADD COLUMN "property" TEXT NOT NULL DEFAULT E'users',
19 | ADD COLUMN "strategy" "Strategy" NOT NULL DEFAULT E'IN',
20 | ADD COLUMN "values" TEXT[];
21 |
--------------------------------------------------------------------------------
/prisma/migrations/20210904203126_removed_default_fields/migration.sql:
--------------------------------------------------------------------------------
1 | -- AlterTable
2 | ALTER TABLE "Heat" ALTER COLUMN "type" DROP DEFAULT,
3 | ALTER COLUMN "property" DROP DEFAULT,
4 | ALTER COLUMN "strategy" DROP DEFAULT;
5 |
--------------------------------------------------------------------------------
/prisma/migrations/20210904205403_audit_logs/migration.sql:
--------------------------------------------------------------------------------
1 | -- CreateTable
2 | CREATE TABLE "AuditLog" (
3 | "id" TEXT NOT NULL,
4 | "flagId" TEXT,
5 | "type" TEXT NOT NULL,
6 | "before" JSONB,
7 | "after" JSONB NOT NULL,
8 | "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
9 | "updated_at" TIMESTAMP(3) NOT NULL,
10 |
11 | PRIMARY KEY ("id")
12 | );
13 |
14 | -- AddForeignKey
15 | ALTER TABLE "AuditLog" ADD FOREIGN KEY ("flagId") REFERENCES "FeatureFlag"("id") ON DELETE SET NULL ON UPDATE CASCADE;
16 |
--------------------------------------------------------------------------------
/prisma/migrations/20210905072832_add_user_to_audit_log/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - Added the required column `userId` to the `AuditLog` table without a default value. This is not possible if the table is not empty.
5 | - Made the column `flagId` on table `AuditLog` required. This step will fail if there are existing NULL values in that column.
6 |
7 | */
8 | -- AlterTable
9 | ALTER TABLE "AuditLog" ADD COLUMN "userId" INTEGER NOT NULL,
10 | ALTER COLUMN "flagId" SET NOT NULL;
11 |
12 | -- AddForeignKey
13 | ALTER TABLE "AuditLog" ADD FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
14 |
--------------------------------------------------------------------------------
/prisma/migrations/20210906125329_correct_mandatory_fields/migration.sql:
--------------------------------------------------------------------------------
1 | /*
2 | Warnings:
3 |
4 | - Made the column `projectId` on table `FeatureFlag` required. This step will fail if there are existing NULL values in that column.
5 | - Made the column `flagId` on table `Heat` required. This step will fail if there are existing NULL values in that column.
6 |
7 | */
8 | -- AlterTable
9 | ALTER TABLE "FeatureFlag" ALTER COLUMN "projectId" SET NOT NULL;
10 |
11 | -- AlterTable
12 | ALTER TABLE "Heat" ALTER COLUMN "flagId" SET NOT NULL;
13 |
--------------------------------------------------------------------------------
/prisma/migrations/migration_lock.toml:
--------------------------------------------------------------------------------
1 | # Please do not edit this file manually
2 | # It should be added in your version-control system (i.e. Git)
3 | provider = "postgresql"
--------------------------------------------------------------------------------
/prisma/schema.prisma:
--------------------------------------------------------------------------------
1 | datasource db {
2 | provider = "postgresql"
3 | url = env("DATABASE_URL")
4 | }
5 |
6 | generator client {
7 | provider = "prisma-client-js"
8 | }
9 |
10 | // Auth related schema
11 |
12 | model Account {
13 | id Int @id @default(autoincrement())
14 | compoundId String @unique @map(name: "compound_id")
15 | userId Int @map(name: "user_id")
16 | providerType String @map(name: "provider_type")
17 | providerId String @map(name: "provider_id")
18 | providerAccountId String @map(name: "provider_account_id")
19 | refreshToken String? @map(name: "refresh_token")
20 | accessToken String? @map(name: "access_token")
21 | accessTokenExpires DateTime? @map(name: "access_token_expires")
22 | createdAt DateTime @default(now()) @map(name: "created_at")
23 | updatedAt DateTime @default(now()) @map(name: "updated_at")
24 |
25 |
26 | @@index([providerAccountId], name: "providerAccountId")
27 | @@index([providerId], name: "providerId")
28 | @@index([userId], name: "userId")
29 | @@map(name: "accounts")
30 | }
31 |
32 | model Session {
33 | id Int @id @default(autoincrement())
34 | userId Int @map(name: "user_id")
35 | expires DateTime
36 | sessionToken String @unique @map(name: "session_token")
37 | accessToken String @unique @map(name: "access_token")
38 | createdAt DateTime @default(now()) @map(name: "created_at")
39 | updatedAt DateTime @default(now()) @map(name: "updated_at")
40 |
41 | @@map(name: "sessions")
42 | }
43 |
44 | model User {
45 | id Int @id @default(autoincrement())
46 | name String?
47 | email String? @unique
48 | emailVerified DateTime? @map(name: "email_verified")
49 | image String?
50 | createdAt DateTime @default(now()) @map(name: "created_at")
51 | updatedAt DateTime @default(now()) @map(name: "updated_at")
52 |
53 | projects Project[]
54 | AuditLog AuditLog[]
55 |
56 | @@map(name: "users")
57 | }
58 |
59 | model VerificationRequest {
60 | id Int @id @default(autoincrement())
61 | identifier String
62 | token String @unique
63 | expires DateTime
64 | createdAt DateTime @default(now()) @map(name: "created_at")
65 | updatedAt DateTime @default(now()) @map(name: "updated_at")
66 |
67 | @@map(name: "verification_requests")
68 | }
69 |
70 | // Business logic related schema
71 |
72 | model Project {
73 | id String @id @default(cuid())
74 | name String
75 | description String?
76 | userId String
77 | createdAt DateTime @default(now()) @map(name: "created_at")
78 | updatedAt DateTime @updatedAt @map(name: "updated_at")
79 | isArchived Boolean @default(false)
80 | featureFlags FeatureFlag[]
81 |
82 | User User? @relation(fields: [userId_ProjectToUser], references: [id])
83 | userId_ProjectToUser Int?
84 | @@map(name: "projects")
85 | }
86 |
87 | model FeatureFlag {
88 | id String @id @default(cuid())
89 | projectId String
90 | name String
91 | description String?
92 | isActive Boolean @default(false)
93 | isArchived Boolean @default(false)
94 | createdAt DateTime @default(now()) @map(name: "created_at")
95 | updatedAt DateTime @updatedAt @map(name: "updated_at")
96 | heats Heat[]
97 |
98 | Project Project @relation(fields: [projectId], references: [id])
99 | AuditLog AuditLog[]
100 | }
101 |
102 | model Heat {
103 | id String @id @default(cuid())
104 | flagId String
105 | type HeatType
106 | strategy Strategy
107 | property String
108 | values String[]
109 | name String?
110 | createdAt DateTime @default(now()) @map(name: "created_at")
111 | updatedAt DateTime @updatedAt @map(name: "updated_at")
112 |
113 |
114 | FeatureFlag FeatureFlag @relation(fields: [flagId], references: [id])
115 | }
116 |
117 | model AuditLog {
118 | id String @id @default(cuid())
119 | userId Int
120 | flagId String
121 | type String
122 | before Json?
123 | after Json
124 |
125 | FeatureFlag FeatureFlag @relation(fields: [flagId], references: [id])
126 | User User @relation(fields: [userId], references: [id])
127 | createdAt DateTime @default(now()) @map(name: "created_at")
128 | updatedAt DateTime @updatedAt @map(name: "updated_at")
129 | }
130 |
131 | enum HeatType {
132 | ENVIRONMENT
133 | USER_INCLUDE
134 | USER_EXCLUDE
135 | CUSTOM
136 | }
137 |
138 | enum Strategy {
139 | IN
140 | NOT_IN
141 | }
142 |
--------------------------------------------------------------------------------
/prisma/seed.ts:
--------------------------------------------------------------------------------
1 | import { PrismaClient } from "@prisma/client";
2 | const prisma = new PrismaClient();
3 |
4 | async function main() {
5 | await prisma.user.upsert({
6 | where: { id: 1 },
7 | update: {},
8 | create: {
9 | name: "alpha@me.com",
10 | },
11 | });
12 | }
13 |
14 | main()
15 | .catch((e) => {
16 | console.error(e);
17 | process.exit(1);
18 | })
19 | .finally(async () => {
20 | await prisma.$disconnect();
21 | });
22 |
--------------------------------------------------------------------------------
/public/logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/public/mockServiceWorker.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable */
2 | /* tslint:disable */
3 |
4 | /**
5 | * Mock Service Worker (0.35.0).
6 | * @see https://github.com/mswjs/msw
7 | * - Please do NOT modify this file.
8 | * - Please do NOT serve this file on production.
9 | */
10 |
11 | const INTEGRITY_CHECKSUM = 'f0a916b13c8acc2b526a03a6d26df85f'
12 | const bypassHeaderName = 'x-msw-bypass'
13 | const activeClientIds = new Set()
14 |
15 | self.addEventListener('install', function () {
16 | return self.skipWaiting()
17 | })
18 |
19 | self.addEventListener('activate', async function (event) {
20 | return self.clients.claim()
21 | })
22 |
23 | self.addEventListener('message', async function (event) {
24 | const clientId = event.source.id
25 |
26 | if (!clientId || !self.clients) {
27 | return
28 | }
29 |
30 | const client = await self.clients.get(clientId)
31 |
32 | if (!client) {
33 | return
34 | }
35 |
36 | const allClients = await self.clients.matchAll()
37 |
38 | switch (event.data) {
39 | case 'KEEPALIVE_REQUEST': {
40 | sendToClient(client, {
41 | type: 'KEEPALIVE_RESPONSE',
42 | })
43 | break
44 | }
45 |
46 | case 'INTEGRITY_CHECK_REQUEST': {
47 | sendToClient(client, {
48 | type: 'INTEGRITY_CHECK_RESPONSE',
49 | payload: INTEGRITY_CHECKSUM,
50 | })
51 | break
52 | }
53 |
54 | case 'MOCK_ACTIVATE': {
55 | activeClientIds.add(clientId)
56 |
57 | sendToClient(client, {
58 | type: 'MOCKING_ENABLED',
59 | payload: true,
60 | })
61 | break
62 | }
63 |
64 | case 'MOCK_DEACTIVATE': {
65 | activeClientIds.delete(clientId)
66 | break
67 | }
68 |
69 | case 'CLIENT_CLOSED': {
70 | activeClientIds.delete(clientId)
71 |
72 | const remainingClients = allClients.filter((client) => {
73 | return client.id !== clientId
74 | })
75 |
76 | // Unregister itself when there are no more clients
77 | if (remainingClients.length === 0) {
78 | self.registration.unregister()
79 | }
80 |
81 | break
82 | }
83 | }
84 | })
85 |
86 | // Resolve the "master" client for the given event.
87 | // Client that issues a request doesn't necessarily equal the client
88 | // that registered the worker. It's with the latter the worker should
89 | // communicate with during the response resolving phase.
90 | async function resolveMasterClient(event) {
91 | const client = await self.clients.get(event.clientId)
92 |
93 | if (client.frameType === 'top-level') {
94 | return client
95 | }
96 |
97 | const allClients = await self.clients.matchAll()
98 |
99 | return allClients
100 | .filter((client) => {
101 | // Get only those clients that are currently visible.
102 | return client.visibilityState === 'visible'
103 | })
104 | .find((client) => {
105 | // Find the client ID that's recorded in the
106 | // set of clients that have registered the worker.
107 | return activeClientIds.has(client.id)
108 | })
109 | }
110 |
111 | async function handleRequest(event, requestId) {
112 | const client = await resolveMasterClient(event)
113 | const response = await getResponse(event, client, requestId)
114 |
115 | // Send back the response clone for the "response:*" life-cycle events.
116 | // Ensure MSW is active and ready to handle the message, otherwise
117 | // this message will pend indefinitely.
118 | if (client && activeClientIds.has(client.id)) {
119 | ;(async function () {
120 | const clonedResponse = response.clone()
121 | sendToClient(client, {
122 | type: 'RESPONSE',
123 | payload: {
124 | requestId,
125 | type: clonedResponse.type,
126 | ok: clonedResponse.ok,
127 | status: clonedResponse.status,
128 | statusText: clonedResponse.statusText,
129 | body:
130 | clonedResponse.body === null ? null : await clonedResponse.text(),
131 | headers: serializeHeaders(clonedResponse.headers),
132 | redirected: clonedResponse.redirected,
133 | },
134 | })
135 | })()
136 | }
137 |
138 | return response
139 | }
140 |
141 | async function getResponse(event, client, requestId) {
142 | const { request } = event
143 | const requestClone = request.clone()
144 | const getOriginalResponse = () => fetch(requestClone)
145 |
146 | // Bypass mocking when the request client is not active.
147 | if (!client) {
148 | return getOriginalResponse()
149 | }
150 |
151 | // Bypass initial page load requests (i.e. static assets).
152 | // The absence of the immediate/parent client in the map of the active clients
153 | // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
154 | // and is not ready to handle requests.
155 | if (!activeClientIds.has(client.id)) {
156 | return await getOriginalResponse()
157 | }
158 |
159 | // Bypass requests with the explicit bypass header
160 | if (requestClone.headers.get(bypassHeaderName) === 'true') {
161 | const cleanRequestHeaders = serializeHeaders(requestClone.headers)
162 |
163 | // Remove the bypass header to comply with the CORS preflight check.
164 | delete cleanRequestHeaders[bypassHeaderName]
165 |
166 | const originalRequest = new Request(requestClone, {
167 | headers: new Headers(cleanRequestHeaders),
168 | })
169 |
170 | return fetch(originalRequest)
171 | }
172 |
173 | // Send the request to the client-side MSW.
174 | const reqHeaders = serializeHeaders(request.headers)
175 | const body = await request.text()
176 |
177 | const clientMessage = await sendToClient(client, {
178 | type: 'REQUEST',
179 | payload: {
180 | id: requestId,
181 | url: request.url,
182 | method: request.method,
183 | headers: reqHeaders,
184 | cache: request.cache,
185 | mode: request.mode,
186 | credentials: request.credentials,
187 | destination: request.destination,
188 | integrity: request.integrity,
189 | redirect: request.redirect,
190 | referrer: request.referrer,
191 | referrerPolicy: request.referrerPolicy,
192 | body,
193 | bodyUsed: request.bodyUsed,
194 | keepalive: request.keepalive,
195 | },
196 | })
197 |
198 | switch (clientMessage.type) {
199 | case 'MOCK_SUCCESS': {
200 | return delayPromise(
201 | () => respondWithMock(clientMessage),
202 | clientMessage.payload.delay,
203 | )
204 | }
205 |
206 | case 'MOCK_NOT_FOUND': {
207 | return getOriginalResponse()
208 | }
209 |
210 | case 'NETWORK_ERROR': {
211 | const { name, message } = clientMessage.payload
212 | const networkError = new Error(message)
213 | networkError.name = name
214 |
215 | // Rejecting a request Promise emulates a network error.
216 | throw networkError
217 | }
218 |
219 | case 'INTERNAL_ERROR': {
220 | const parsedBody = JSON.parse(clientMessage.payload.body)
221 |
222 | console.error(
223 | `\
224 | [MSW] Uncaught exception in the request handler for "%s %s":
225 |
226 | ${parsedBody.location}
227 |
228 | This exception has been gracefully handled as a 500 response, however, it's strongly recommended to resolve this error, as it indicates a mistake in your code. If you wish to mock an error response, please see this guide: https://mswjs.io/docs/recipes/mocking-error-responses\
229 | `,
230 | request.method,
231 | request.url,
232 | )
233 |
234 | return respondWithMock(clientMessage)
235 | }
236 | }
237 |
238 | return getOriginalResponse()
239 | }
240 |
241 | self.addEventListener('fetch', function (event) {
242 | const { request } = event
243 | const accept = request.headers.get('accept') || ''
244 |
245 | // Bypass server-sent events.
246 | if (accept.includes('text/event-stream')) {
247 | return
248 | }
249 |
250 | // Bypass navigation requests.
251 | if (request.mode === 'navigate') {
252 | return
253 | }
254 |
255 | // Opening the DevTools triggers the "only-if-cached" request
256 | // that cannot be handled by the worker. Bypass such requests.
257 | if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
258 | return
259 | }
260 |
261 | // Bypass all requests when there are no active clients.
262 | // Prevents the self-unregistered worked from handling requests
263 | // after it's been deleted (still remains active until the next reload).
264 | if (activeClientIds.size === 0) {
265 | return
266 | }
267 |
268 | const requestId = uuidv4()
269 |
270 | return event.respondWith(
271 | handleRequest(event, requestId).catch((error) => {
272 | if (error.name === 'NetworkError') {
273 | console.warn(
274 | '[MSW] Successfully emulated a network error for the "%s %s" request.',
275 | request.method,
276 | request.url,
277 | )
278 | return
279 | }
280 |
281 | // At this point, any exception indicates an issue with the original request/response.
282 | console.error(
283 | `\
284 | [MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`,
285 | request.method,
286 | request.url,
287 | `${error.name}: ${error.message}`,
288 | )
289 | }),
290 | )
291 | })
292 |
293 | function serializeHeaders(headers) {
294 | const reqHeaders = {}
295 | headers.forEach((value, name) => {
296 | reqHeaders[name] = reqHeaders[name]
297 | ? [].concat(reqHeaders[name]).concat(value)
298 | : value
299 | })
300 | return reqHeaders
301 | }
302 |
303 | function sendToClient(client, message) {
304 | return new Promise((resolve, reject) => {
305 | const channel = new MessageChannel()
306 |
307 | channel.port1.onmessage = (event) => {
308 | if (event.data && event.data.error) {
309 | return reject(event.data.error)
310 | }
311 |
312 | resolve(event.data)
313 | }
314 |
315 | client.postMessage(JSON.stringify(message), [channel.port2])
316 | })
317 | }
318 |
319 | function delayPromise(cb, duration) {
320 | return new Promise((resolve) => {
321 | setTimeout(() => resolve(cb()), duration)
322 | })
323 | }
324 |
325 | function respondWithMock(clientMessage) {
326 | return new Response(clientMessage.payload.body, {
327 | ...clientMessage.payload,
328 | headers: clientMessage.payload.headers,
329 | })
330 | }
331 |
332 | function uuidv4() {
333 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
334 | const r = (Math.random() * 16) | 0
335 | const v = c == 'x' ? r : (r & 0x3) | 0x8
336 | return v.toString(16)
337 | })
338 | }
339 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "strict": false,
12 | "forceConsistentCasingInFileNames": true,
13 | "noEmit": true,
14 | "esModuleInterop": true,
15 | "module": "esnext",
16 | "moduleResolution": "node",
17 | "resolveJsonModule": true,
18 | "isolatedModules": true,
19 | "jsx": "preserve",
20 | "baseUrl": ".",
21 | "typeRoots":["types"]
22 | },
23 | "include": [
24 | "next-env.d.ts",
25 | "**/*.ts",
26 | "**/*.tsx"
27 | , "next.config.js", "prisma/db" ],
28 | "exclude": [
29 | "node_modules"
30 | ]
31 | }
32 |
--------------------------------------------------------------------------------
/types/next-auth.d.ts:
--------------------------------------------------------------------------------
1 | import NextAuth from "next-auth";
2 |
3 | declare module "next-auth" {
4 | interface Session {
5 | user: {
6 | id: number;
7 | email: string;
8 | name: string;
9 | createdAt: Date;
10 | };
11 | }
12 | }
13 |
--------------------------------------------------------------------------------