chore!: migrate to docker environment
This commit is contained in:
parent
543cfad176
commit
9497620acd
1
.docker/.buildNodeID
Normal file
1
.docker/.buildNodeID
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
4a7b79dacb6e1e604537fbbb4cd8ac7f18e0a0f4f5ed963d9914d7753dc170af
|
||||||
5
.docker/.token_seed
Normal file
5
.docker/.token_seed
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"registry-1.docker.io": {
|
||||||
|
"Seed": "0Vwpc9pKO8P+IoVikWF8Mw=="
|
||||||
|
}
|
||||||
|
}
|
||||||
0
.docker/.token_seed.lock
Normal file
0
.docker/.token_seed.lock
Normal file
2
.dockerignore
Normal file
2
.dockerignore
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
node_modules
|
||||||
|
docs
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/timer"
|
DATABASE_URL="file:./data.db?connection_limit=1"
|
||||||
SESSION_SECRET="za1W297qRgKq6PNtm5EXJlOfIto6WTS"
|
SESSION_SECRET="za1W297qRgKq6PNtm5EXJlOfIto6WTS"
|
||||||
ALLOW_USER_SIGNUP=1 # remove this line to disable user signup
|
ALLOW_USER_SIGNUP=1 # remove this line to disable user signup
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -15,3 +15,6 @@ yarn-error.log
|
||||||
/build
|
/build
|
||||||
/public/build
|
/public/build
|
||||||
.env
|
.env
|
||||||
|
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
|
|
||||||
67
Dockerfile
Normal file
67
Dockerfile
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
# base node image
|
||||||
|
FROM node:16-bullseye-slim as base
|
||||||
|
|
||||||
|
# set for base and all layer that inherit from it
|
||||||
|
ENV NODE_ENV production
|
||||||
|
|
||||||
|
# Install openssl for Prisma
|
||||||
|
RUN apt-get update && apt-get install -y openssl sqlite3
|
||||||
|
|
||||||
|
# Install all node_modules, including dev dependencies
|
||||||
|
FROM base as deps
|
||||||
|
|
||||||
|
WORKDIR /timer
|
||||||
|
|
||||||
|
ADD package.json .npmrc ./
|
||||||
|
RUN npm install --include=dev
|
||||||
|
|
||||||
|
# Setup production node_modules
|
||||||
|
FROM base as production-deps
|
||||||
|
|
||||||
|
WORKDIR /timer
|
||||||
|
|
||||||
|
COPY --from=deps /timer/node_modules /timer/node_modules
|
||||||
|
ADD package.json .npmrc ./
|
||||||
|
RUN npm prune --omit=dev
|
||||||
|
|
||||||
|
# Build the app
|
||||||
|
FROM base as build
|
||||||
|
|
||||||
|
WORKDIR /timer
|
||||||
|
|
||||||
|
COPY --from=deps /timer/node_modules /timer/node_modules
|
||||||
|
|
||||||
|
ADD prisma .
|
||||||
|
RUN npx prisma generate
|
||||||
|
|
||||||
|
ADD . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# Finally, build the production image with minimal footprint
|
||||||
|
FROM base
|
||||||
|
|
||||||
|
ENV DATABASE_URL=file:/data/sqlite.db
|
||||||
|
ENV PORT="8080"
|
||||||
|
ENV NODE_ENV="production"
|
||||||
|
ENV SESSION_SECRET="${SESSION_SECRET:-za1W297qRgKq6PNtm5EXJlOfIto6WTS}"
|
||||||
|
ENV ALLOW_USER_SIGNUP=0
|
||||||
|
|
||||||
|
# add shortcut for connecting to database CLI
|
||||||
|
RUN echo "#!/bin/sh\nset -x\nsqlite3 \$DATABASE_URL" > /usr/local/bin/database-cli && chmod +x /usr/local/bin/database-cli
|
||||||
|
|
||||||
|
WORKDIR /timer
|
||||||
|
|
||||||
|
COPY --from=production-deps /timer/node_modules /timer/node_modules
|
||||||
|
COPY --from=build /timer/node_modules/.prisma /timer/node_modules/.prisma
|
||||||
|
|
||||||
|
COPY --from=build /timer/build /timer/build
|
||||||
|
COPY --from=build /timer/public /timer/public
|
||||||
|
COPY --from=build /timer/package.json /timer/package.json
|
||||||
|
COPY --from=build /timer/start.sh /timer/start.sh
|
||||||
|
COPY --from=build /timer/prisma /timer/prisma
|
||||||
|
|
||||||
|
RUN chmod +x start.sh
|
||||||
|
|
||||||
|
ENTRYPOINT [ "./start.sh" ]
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
@ -1,7 +1,24 @@
|
||||||
export const ALLOW_USER_SIGNUP = Boolean(
|
import { getSetting } from './models/settings.server';
|
||||||
process.env.ALLOW_USER_SIGNUP || false
|
import { countUsers } from './models/user.server';
|
||||||
);
|
|
||||||
|
export const ALLOW_USER_SIGNUP = process.env.ALLOW_USER_SIGNUP === '1' || false;
|
||||||
|
|
||||||
|
export const isSignupAllowed = async () => {
|
||||||
|
const allowUserSignup = await getSetting({ id: 'ALLOW_USER_SIGNUP' });
|
||||||
|
|
||||||
|
if (allowUserSignup?.value !== undefined && allowUserSignup?.value !== null) {
|
||||||
|
return (
|
||||||
|
allowUserSignup.value === 'true' ||
|
||||||
|
allowUserSignup.value === 'yes' ||
|
||||||
|
allowUserSignup.value === '1' ||
|
||||||
|
allowUserSignup.value === 'on'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let isFirstUser = (await countUsers()) === 0;
|
||||||
|
if (isFirstUser) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export const isSignupAllowed = () => {
|
|
||||||
return !!ALLOW_USER_SIGNUP;
|
return !!ALLOW_USER_SIGNUP;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
23
app/models/settings.server.ts
Normal file
23
app/models/settings.server.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import type { Settings } from '@prisma/client';
|
||||||
|
|
||||||
|
import { prisma } from '~/db.server';
|
||||||
|
|
||||||
|
export type { Settings } from '@prisma/client';
|
||||||
|
|
||||||
|
export function getSettings() {
|
||||||
|
return prisma.settings.findMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSetting({ id }: { id: Settings['id'] }) {
|
||||||
|
return prisma.settings.findFirst({
|
||||||
|
where: { id }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateSetting({ id, value }: Settings) {
|
||||||
|
return prisma.settings.upsert({
|
||||||
|
where: { id },
|
||||||
|
update: { value },
|
||||||
|
create: { id, value }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -13,12 +13,17 @@ export async function getUserByEmail(email: User['email']) {
|
||||||
return prisma.user.findUnique({ where: { email } });
|
return prisma.user.findUnique({ where: { email } });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createUser(email: User['email'], password: string) {
|
export async function createUser(
|
||||||
|
email: User['email'],
|
||||||
|
password: string,
|
||||||
|
admin = false
|
||||||
|
) {
|
||||||
const hashedPassword = await bcrypt.hash(password, 10);
|
const hashedPassword = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
return prisma.user.create({
|
return prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
email,
|
email,
|
||||||
|
admin,
|
||||||
password: {
|
password: {
|
||||||
create: {
|
create: {
|
||||||
hash: hashedPassword
|
hash: hashedPassword
|
||||||
|
|
@ -99,6 +104,10 @@ export async function verifyLogin(
|
||||||
return userWithoutPassword;
|
return userWithoutPassword;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function countUsers() {
|
||||||
|
return prisma.user.count();
|
||||||
|
}
|
||||||
|
|
||||||
export async function getUsers({
|
export async function getUsers({
|
||||||
search,
|
search,
|
||||||
page,
|
page,
|
||||||
|
|
|
||||||
24
app/root.tsx
24
app/root.tsx
|
|
@ -370,6 +370,7 @@ function Layout({ children }: React.PropsWithChildren<{}>) {
|
||||||
}`
|
}`
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<Badge variant="light">ADMIN</Badge>
|
||||||
<NavLink
|
<NavLink
|
||||||
component={Link}
|
component={Link}
|
||||||
to="/users"
|
to="/users"
|
||||||
|
|
@ -379,10 +380,21 @@ function Layout({ children }: React.PropsWithChildren<{}>) {
|
||||||
<Users size={16} />
|
<Users size={16} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
}
|
}
|
||||||
rightSection={<Badge variant="light">ADMIN</Badge>}
|
|
||||||
variant="light"
|
variant="light"
|
||||||
active={location.pathname.includes('/users')}
|
active={location.pathname.includes('/users')}
|
||||||
/>
|
/>
|
||||||
|
<NavLink
|
||||||
|
component={Link}
|
||||||
|
to="/settings"
|
||||||
|
label="Settings"
|
||||||
|
icon={
|
||||||
|
<ThemeIcon variant="light">
|
||||||
|
<Settings size={16} />
|
||||||
|
</ThemeIcon>
|
||||||
|
}
|
||||||
|
variant="light"
|
||||||
|
active={location.pathname.includes('/settings')}
|
||||||
|
/>
|
||||||
</Navbar.Section>
|
</Navbar.Section>
|
||||||
)}
|
)}
|
||||||
{user && (
|
{user && (
|
||||||
|
|
@ -466,7 +478,15 @@ function Layout({ children }: React.PropsWithChildren<{}>) {
|
||||||
>
|
>
|
||||||
<Clock />
|
<Clock />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<span>WorkTimer</span>
|
<span
|
||||||
|
style={{
|
||||||
|
fontWeight: 700,
|
||||||
|
lineHeight: 1.2,
|
||||||
|
fontSize: '1.25rem'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
WorkTimer
|
||||||
|
</span>
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,22 @@
|
||||||
import type { LoaderArgs } from '@remix-run/node';
|
import type { LoaderArgs } from '@remix-run/node';
|
||||||
import { json, redirect } from '@remix-run/node';
|
import { json, redirect } from '@remix-run/node';
|
||||||
|
import {
|
||||||
|
createStyles,
|
||||||
|
Title,
|
||||||
|
SimpleGrid,
|
||||||
|
Text,
|
||||||
|
Button,
|
||||||
|
ThemeIcon,
|
||||||
|
Grid,
|
||||||
|
Col,
|
||||||
|
Image,
|
||||||
|
Container,
|
||||||
|
Group,
|
||||||
|
Box
|
||||||
|
} from '@mantine/core';
|
||||||
|
import { Server, Lock, Users, FileText, GitHub } from 'react-feather';
|
||||||
import { getUserId } from '~/session.server';
|
import { getUserId } from '~/session.server';
|
||||||
|
import { Link } from '@remix-run/react';
|
||||||
|
|
||||||
export async function loader({ request }: LoaderArgs) {
|
export async function loader({ request }: LoaderArgs) {
|
||||||
const userId = await getUserId(request);
|
const userId = await getUserId(request);
|
||||||
|
|
@ -9,35 +24,254 @@ export async function loader({ request }: LoaderArgs) {
|
||||||
return json({});
|
return json({});
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Index() {
|
const features = [
|
||||||
return (
|
{
|
||||||
<div style={{ fontFamily: 'system-ui, sans-serif', lineHeight: '1.4' }}>
|
icon: FileText,
|
||||||
<h1>Welcome to Remix</h1>
|
title: 'Free and open source',
|
||||||
<ul>
|
description:
|
||||||
<li>
|
'All packages are published under GNU Public license v3, you can self host this app and use it for free forever'
|
||||||
<a
|
},
|
||||||
target="_blank"
|
{
|
||||||
href="https://remix.run/tutorials/blog"
|
icon: Server,
|
||||||
rel="noreferrer"
|
title: 'Host anywhere',
|
||||||
|
description:
|
||||||
|
'You can host this app on your own server or using any cloud provider, your choice'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Lock,
|
||||||
|
title: 'Privacy friendly, you own your data',
|
||||||
|
description:
|
||||||
|
'No analytics or tracking scripts, no ads, no data sharing. You are in control of your data'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: Users,
|
||||||
|
title: 'Flexible',
|
||||||
|
description:
|
||||||
|
'Use it for yourself as single user or invite your team to collaborate, you can also use it as a public service as admin'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const items = features.map((feature) => (
|
||||||
|
<div key={feature.title}>
|
||||||
|
<ThemeIcon
|
||||||
|
size={44}
|
||||||
|
radius="md"
|
||||||
|
variant="gradient"
|
||||||
|
gradient={{ deg: 133, from: 'blue', to: 'cyan' }}
|
||||||
>
|
>
|
||||||
15m Quickstart Blog Tutorial
|
<feature.icon />
|
||||||
</a>
|
</ThemeIcon>
|
||||||
</li>
|
<Text fz="lg" mt="sm" fw={500}>
|
||||||
<li>
|
{feature.title}
|
||||||
<a
|
</Text>
|
||||||
target="_blank"
|
<Text c="dimmed" fz="sm">
|
||||||
href="https://remix.run/tutorials/jokes"
|
{feature.description}
|
||||||
rel="noreferrer"
|
</Text>
|
||||||
>
|
|
||||||
Deep Dive Jokes App Tutorial
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a target="_blank" href="https://remix.run/docs" rel="noreferrer">
|
|
||||||
Remix Docs
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
));
|
||||||
|
|
||||||
|
const rem = (value: number) => `${value / 16}rem`;
|
||||||
|
|
||||||
|
const useStyles = createStyles((theme) => ({
|
||||||
|
wrapper: {
|
||||||
|
position: 'relative',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
backgroundColor:
|
||||||
|
theme.colorScheme === 'dark' ? theme.colors.dark[8] : theme.white
|
||||||
|
},
|
||||||
|
|
||||||
|
inner: {
|
||||||
|
position: 'relative',
|
||||||
|
paddingTop: rem(32),
|
||||||
|
paddingBottom: rem(32),
|
||||||
|
|
||||||
|
[theme.fn.smallerThan('sm')]: {
|
||||||
|
paddingBottom: rem(16),
|
||||||
|
paddingTop: rem(16)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
title: {
|
||||||
|
fontSize: rem(62),
|
||||||
|
fontWeight: 900,
|
||||||
|
lineHeight: 1.1,
|
||||||
|
margin: 0,
|
||||||
|
padding: 0,
|
||||||
|
color: theme.colorScheme === 'dark' ? theme.white : theme.black,
|
||||||
|
|
||||||
|
[theme.fn.smallerThan('sm')]: {
|
||||||
|
fontSize: rem(42),
|
||||||
|
lineHeight: 1.2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
description: {
|
||||||
|
marginTop: theme.spacing.xl,
|
||||||
|
fontSize: rem(24),
|
||||||
|
|
||||||
|
[theme.fn.smallerThan('sm')]: {
|
||||||
|
fontSize: rem(18)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
controls: {
|
||||||
|
marginTop: `calc(${theme.spacing.xl}px * 2)`,
|
||||||
|
|
||||||
|
[theme.fn.smallerThan('sm')]: {
|
||||||
|
marginTop: theme.spacing.xl
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
control: {
|
||||||
|
height: rem(54),
|
||||||
|
paddingLeft: rem(38),
|
||||||
|
paddingRight: rem(38),
|
||||||
|
|
||||||
|
[theme.fn.smallerThan('sm')]: {
|
||||||
|
height: rem(54),
|
||||||
|
paddingLeft: rem(18),
|
||||||
|
paddingRight: rem(18),
|
||||||
|
flex: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
const { classes } = useStyles();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Container size="md" px="md" className={classes.inner}>
|
||||||
|
<h1 className={classes.title}>
|
||||||
|
A{' '}
|
||||||
|
<Text
|
||||||
|
component="span"
|
||||||
|
variant="gradient"
|
||||||
|
gradient={{ from: 'blue', to: 'cyan' }}
|
||||||
|
inherit
|
||||||
|
>
|
||||||
|
self-hosted
|
||||||
|
</Text>{' '}
|
||||||
|
privacy friendly time tracking app
|
||||||
|
</h1>
|
||||||
|
<Text className={classes.description} color="dimmed">
|
||||||
|
Time tracking app built with Remix, supports authentication, projects
|
||||||
|
management, and monthly or custom reports
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Group className={classes.controls}>
|
||||||
|
<Button
|
||||||
|
component={Link}
|
||||||
|
to="/login"
|
||||||
|
size="xl"
|
||||||
|
className={classes.control}
|
||||||
|
variant="gradient"
|
||||||
|
gradient={{ from: 'blue', to: 'cyan' }}
|
||||||
|
>
|
||||||
|
Get started
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
component="a"
|
||||||
|
href="https://github.com/nzambello/work-timer"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
size="xl"
|
||||||
|
variant="default"
|
||||||
|
className={classes.control}
|
||||||
|
leftIcon={<GitHub />}
|
||||||
|
>
|
||||||
|
GitHub
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Container>
|
||||||
|
|
||||||
|
<Container size="md" px="md" mt={120}>
|
||||||
|
<Title mt="xl" mb="md" order={2}>
|
||||||
|
Features
|
||||||
|
</Title>
|
||||||
|
<SimpleGrid
|
||||||
|
mb="xl"
|
||||||
|
cols={2}
|
||||||
|
spacing={30}
|
||||||
|
breakpoints={[{ maxWidth: 'md', cols: 1 }]}
|
||||||
|
>
|
||||||
|
{items}
|
||||||
|
</SimpleGrid>
|
||||||
|
|
||||||
|
<Grid gutter="lg" mt={120} align="flex-start">
|
||||||
|
<Col span={12} md={7}>
|
||||||
|
<Title order={3} mb="sm">
|
||||||
|
Light/dark theme
|
||||||
|
</Title>
|
||||||
|
<Group noWrap>
|
||||||
|
<Image
|
||||||
|
maw="50%"
|
||||||
|
mx="auto"
|
||||||
|
radius="md"
|
||||||
|
src="/images/00-time-entries-light.png"
|
||||||
|
alt="Time entries (light theme)"
|
||||||
|
/>
|
||||||
|
<Image
|
||||||
|
maw="50%"
|
||||||
|
mx="auto"
|
||||||
|
radius="md"
|
||||||
|
src="/images/01-time-entries-dark.png"
|
||||||
|
alt="Time entries (dark theme)"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Col>
|
||||||
|
<Col span={12} md={5}>
|
||||||
|
<Title order={3} mb="sm">
|
||||||
|
Time entries management
|
||||||
|
</Title>
|
||||||
|
<Group noWrap>
|
||||||
|
<Image
|
||||||
|
maw="100%"
|
||||||
|
mx="auto"
|
||||||
|
radius="md"
|
||||||
|
src="/images/02-new-time-entry.png"
|
||||||
|
alt="Time entries editing"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Col>
|
||||||
|
<Col span={12} md={5}>
|
||||||
|
<Title order={3} mb="sm">
|
||||||
|
Reports
|
||||||
|
</Title>
|
||||||
|
<Group noWrap>
|
||||||
|
<Image
|
||||||
|
maw="100%"
|
||||||
|
mx="auto"
|
||||||
|
radius="md"
|
||||||
|
src="/images/05-reports.png"
|
||||||
|
alt="Reports"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Col>
|
||||||
|
<Col span={12} md={7}>
|
||||||
|
<Title order={3} mb="sm">
|
||||||
|
Projects
|
||||||
|
</Title>
|
||||||
|
<Group noWrap>
|
||||||
|
<Image
|
||||||
|
maw="50%"
|
||||||
|
mx="auto"
|
||||||
|
radius="md"
|
||||||
|
src="/images/03-projects.png"
|
||||||
|
alt="Projects management"
|
||||||
|
/>
|
||||||
|
<Image
|
||||||
|
maw="50%"
|
||||||
|
mx="auto"
|
||||||
|
radius="md"
|
||||||
|
src="/images/04-new-project.png"
|
||||||
|
alt="Projects management: new project"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Col>
|
||||||
|
</Grid>
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import {
|
||||||
Title
|
Title
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { AtSign, Lock } from 'react-feather';
|
import { AtSign, Lock } from 'react-feather';
|
||||||
import { verifyLogin } from '~/models/user.server';
|
import { countUsers, verifyLogin } from '~/models/user.server';
|
||||||
import { createUserSession, getUserId } from '~/session.server';
|
import { createUserSession, getUserId } from '~/session.server';
|
||||||
import { safeRedirect, validateEmail } from '~/utils';
|
import { safeRedirect, validateEmail } from '~/utils';
|
||||||
import { isSignupAllowed } from '~/config.server';
|
import { isSignupAllowed } from '~/config.server';
|
||||||
|
|
@ -26,8 +26,11 @@ export async function loader({ request }: LoaderArgs) {
|
||||||
const userId = await getUserId(request);
|
const userId = await getUserId(request);
|
||||||
if (userId) return redirect('/time-entries');
|
if (userId) return redirect('/time-entries');
|
||||||
|
|
||||||
|
const isFirstUser = (await countUsers()) === 0;
|
||||||
|
if (isFirstUser) return redirect('/signup');
|
||||||
|
|
||||||
return json({
|
return json({
|
||||||
ALLOW_USER_SIGNUP: isSignupAllowed()
|
ALLOW_USER_SIGNUP: await isSignupAllowed()
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
176
app/routes/settings.tsx
Normal file
176
app/routes/settings.tsx
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Paper,
|
||||||
|
Checkbox,
|
||||||
|
TextInput,
|
||||||
|
Alert,
|
||||||
|
Container
|
||||||
|
} from '@mantine/core';
|
||||||
|
import {
|
||||||
|
ActionArgs,
|
||||||
|
json,
|
||||||
|
LoaderArgs,
|
||||||
|
MetaFunction,
|
||||||
|
redirect
|
||||||
|
} from '@remix-run/node';
|
||||||
|
import { Form, useCatch, useLoaderData } from '@remix-run/react';
|
||||||
|
import { requireAdminUserId } from '~/session.server';
|
||||||
|
import { getSettings, updateSetting } from '~/models/settings.server';
|
||||||
|
import { Settings } from '~/models/settings.server';
|
||||||
|
import { isSignupAllowed } from '~/config.server';
|
||||||
|
import { AlertTriangle } from 'react-feather';
|
||||||
|
|
||||||
|
export const meta: MetaFunction = () => {
|
||||||
|
return {
|
||||||
|
title: 'Settings | WorkTimer',
|
||||||
|
description:
|
||||||
|
'Manage your WorkTimer instance. You must be logged in to do this.'
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loader({ request }: LoaderArgs) {
|
||||||
|
const userId = await requireAdminUserId(request);
|
||||||
|
if (!userId) return redirect('/login');
|
||||||
|
|
||||||
|
const settings = await getSettings();
|
||||||
|
|
||||||
|
if (!settings || !settings.find((s) => s.id === 'ALLOW_USER_SIGNUP')) {
|
||||||
|
return json({
|
||||||
|
settings: [
|
||||||
|
...((settings || []).filter((s) => s.id !== 'ALLOW_USER_SIGNUP') || []),
|
||||||
|
{
|
||||||
|
id: 'ALLOW_USER_SIGNUP',
|
||||||
|
value: (await isSignupAllowed()) ? 'true' : 'false'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return json({
|
||||||
|
settings
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function action({ request }: ActionArgs) {
|
||||||
|
await requireAdminUserId(request);
|
||||||
|
|
||||||
|
const formData = await request.formData();
|
||||||
|
const id = (formData.get('id') || undefined) as string | undefined;
|
||||||
|
const value = (formData.get('value') || undefined) as
|
||||||
|
| string
|
||||||
|
| boolean
|
||||||
|
| undefined;
|
||||||
|
|
||||||
|
if (!id) {
|
||||||
|
throw new Response('Missing setting id', { status: 422 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsedValue;
|
||||||
|
if (value === 'true' || value === 'on' || value === true) {
|
||||||
|
parsedValue = 'true';
|
||||||
|
} else if (value === 'false' || value === 'off' || value === false) {
|
||||||
|
parsedValue = 'false';
|
||||||
|
} else if (typeof value === 'string') {
|
||||||
|
parsedValue = value;
|
||||||
|
} else {
|
||||||
|
parsedValue = 'false';
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateSetting({
|
||||||
|
id,
|
||||||
|
value: parsedValue
|
||||||
|
});
|
||||||
|
|
||||||
|
return redirect('/settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Settings() {
|
||||||
|
const data = useLoaderData<typeof loader>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
width: '1px',
|
||||||
|
height: '1px',
|
||||||
|
padding: 0,
|
||||||
|
margin: '-1px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
clip: 'rect(0,0,0,0)',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
border: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div role="region" id="settings">
|
||||||
|
<Container size="md">
|
||||||
|
{data.settings.map((setting) => (
|
||||||
|
<Form method="patch" key={setting.id}>
|
||||||
|
<input type="hidden" name="id" value={setting.id} />
|
||||||
|
<Paper
|
||||||
|
shadow="sm"
|
||||||
|
p="md"
|
||||||
|
radius="md"
|
||||||
|
mb="sm"
|
||||||
|
display="flex"
|
||||||
|
style={{
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{['on', 'off', 'true', 'false'].includes(setting.value) ? (
|
||||||
|
<Checkbox
|
||||||
|
label={setting.id}
|
||||||
|
id={setting.id}
|
||||||
|
defaultChecked={
|
||||||
|
setting.value === 'true' || setting.value === 'on'
|
||||||
|
}
|
||||||
|
name="value"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<TextInput
|
||||||
|
label={setting.id}
|
||||||
|
id={setting.id}
|
||||||
|
name="value"
|
||||||
|
defaultValue={setting.value}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button size="sm" type="submit">
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</Paper>
|
||||||
|
</Form>
|
||||||
|
))}
|
||||||
|
</Container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorBoundary({ error }: { error: Error }) {
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Alert icon={<AlertTriangle size={14} />} title="Error" color="red">
|
||||||
|
An unexpected error occurred: {error.message}
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CatchBoundary() {
|
||||||
|
const caught = useCatch();
|
||||||
|
|
||||||
|
if (caught.status === 404) {
|
||||||
|
return (
|
||||||
|
<Alert icon={<AlertTriangle size={14} />} title="Error" color="red">
|
||||||
|
Not found
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Unexpected caught response with status: ${caught.status}`);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
import type { ActionArgs, LoaderArgs, MetaFunction } from '@remix-run/node';
|
import type { ActionArgs, LoaderArgs, MetaFunction } from '@remix-run/node';
|
||||||
import { json, redirect } from '@remix-run/node';
|
import { json, redirect } from '@remix-run/node';
|
||||||
import { Form, Link, useActionData, useSearchParams } from '@remix-run/react';
|
import {
|
||||||
|
Form,
|
||||||
|
Link,
|
||||||
|
useActionData,
|
||||||
|
useLoaderData,
|
||||||
|
useSearchParams
|
||||||
|
} from '@remix-run/react';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import {
|
import {
|
||||||
TextInput,
|
TextInput,
|
||||||
|
|
@ -15,7 +21,7 @@ import {
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { AtSign, Check, Lock, X } from 'react-feather';
|
import { AtSign, Check, Lock, X } from 'react-feather';
|
||||||
import { createUserSession, getUserId } from '~/session.server';
|
import { createUserSession, getUserId } from '~/session.server';
|
||||||
import { createUser, getUserByEmail } from '~/models/user.server';
|
import { countUsers, createUser, getUserByEmail } from '~/models/user.server';
|
||||||
import { safeRedirect, validateEmail } from '~/utils';
|
import { safeRedirect, validateEmail } from '~/utils';
|
||||||
import { isSignupAllowed } from '~/config.server';
|
import { isSignupAllowed } from '~/config.server';
|
||||||
|
|
||||||
|
|
@ -23,15 +29,21 @@ export async function loader({ request }: LoaderArgs) {
|
||||||
const userId = await getUserId(request);
|
const userId = await getUserId(request);
|
||||||
if (userId) return redirect('/time-entries');
|
if (userId) return redirect('/time-entries');
|
||||||
|
|
||||||
if (!isSignupAllowed()) {
|
const isFirstUser = (await countUsers()) === 0;
|
||||||
|
|
||||||
|
if (!(await isSignupAllowed())) {
|
||||||
return redirect('/login');
|
return redirect('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
return json({});
|
return json({
|
||||||
|
isFirstUser
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function action({ request }: ActionArgs) {
|
export async function action({ request }: ActionArgs) {
|
||||||
if (!isSignupAllowed()) {
|
const isFirstUser = (await countUsers()) === 0;
|
||||||
|
|
||||||
|
if (!isSignupAllowed() && !isFirstUser) {
|
||||||
return json(
|
return json(
|
||||||
{
|
{
|
||||||
errors: {
|
errors: {
|
||||||
|
|
@ -116,7 +128,7 @@ export async function action({ request }: ActionArgs) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await createUser(email, password);
|
const user = await createUser(email, password, isFirstUser);
|
||||||
|
|
||||||
return createUserSession({
|
return createUserSession({
|
||||||
request,
|
request,
|
||||||
|
|
@ -175,6 +187,7 @@ function getStrength(password: string) {
|
||||||
export default function SignUpPage() {
|
export default function SignUpPage() {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const redirectTo = searchParams.get('redirectTo') ?? undefined;
|
const redirectTo = searchParams.get('redirectTo') ?? undefined;
|
||||||
|
const loaderData = useLoaderData<typeof loader>();
|
||||||
const actionData = useActionData<typeof action>();
|
const actionData = useActionData<typeof action>();
|
||||||
const emailRef = React.useRef<HTMLInputElement>(null);
|
const emailRef = React.useRef<HTMLInputElement>(null);
|
||||||
const passwordRef = React.useRef<HTMLInputElement>(null);
|
const passwordRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
@ -293,6 +306,7 @@ export default function SignUpPage() {
|
||||||
<Button type="submit">Create account</Button>
|
<Button type="submit">Create account</Button>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
|
{!loaderData.isFirstUser && (
|
||||||
<Box
|
<Box
|
||||||
mt="md"
|
mt="md"
|
||||||
sx={{
|
sx={{
|
||||||
|
|
@ -304,6 +318,7 @@ export default function SignUpPage() {
|
||||||
<strong>Log in</strong>
|
<strong>Log in</strong>
|
||||||
</Link>
|
</Link>
|
||||||
</Box>
|
</Box>
|
||||||
|
)}
|
||||||
</Form>
|
</Form>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,18 @@ export async function requireUserId(
|
||||||
return userId;
|
return userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function requireAdminUserId(
|
||||||
|
request: Request,
|
||||||
|
redirectTo: string = new URL(request.url).pathname
|
||||||
|
) {
|
||||||
|
const user = await getUser(request);
|
||||||
|
if (!user || !user.admin) {
|
||||||
|
const searchParams = new URLSearchParams([['redirectTo', redirectTo]]);
|
||||||
|
throw redirect(`/login?${searchParams}`);
|
||||||
|
}
|
||||||
|
return user.id;
|
||||||
|
}
|
||||||
|
|
||||||
export async function requireUser(request: Request) {
|
export async function requireUser(request: Request) {
|
||||||
const userId = await requireUserId(request);
|
const userId = await requireUserId(request);
|
||||||
|
|
||||||
|
|
|
||||||
24712
package-lock.json
generated
Normal file
24712
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -19,13 +19,14 @@
|
||||||
"@mantine/notifications": "5.10.3",
|
"@mantine/notifications": "5.10.3",
|
||||||
"@mantine/nprogress": "5.10.3",
|
"@mantine/nprogress": "5.10.3",
|
||||||
"@mantine/remix": "5.10.3",
|
"@mantine/remix": "5.10.3",
|
||||||
"@prisma/client": "4.10.1",
|
"@prisma/client": "3.9.1",
|
||||||
"@remix-run/node": "^1.15.0",
|
"@remix-run/node": "^1.15.0",
|
||||||
"@remix-run/react": "^1.15.0",
|
"@remix-run/react": "^1.15.0",
|
||||||
"@remix-run/serve": "^1.15.0",
|
"@remix-run/serve": "^1.15.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"dayjs": "1.11.7",
|
"dayjs": "1.11.7",
|
||||||
|
"esbuild": "0.16.3",
|
||||||
"isbot": "^3.6.5",
|
"isbot": "^3.6.5",
|
||||||
"nprogress": "0.2.0",
|
"nprogress": "0.2.0",
|
||||||
"papaparse": "5.3.2",
|
"papaparse": "5.3.2",
|
||||||
|
|
@ -39,8 +40,8 @@
|
||||||
"@commitlint/cli": "17.4.2",
|
"@commitlint/cli": "17.4.2",
|
||||||
"@commitlint/config-conventional": "17.4.2",
|
"@commitlint/config-conventional": "17.4.2",
|
||||||
"@release-it/conventional-changelog": "5.1.1",
|
"@release-it/conventional-changelog": "5.1.1",
|
||||||
"@remix-run/dev": "^1.12.0",
|
"@remix-run/dev": "^1.15.0",
|
||||||
"@remix-run/eslint-config": "^1.12.0",
|
"@remix-run/eslint-config": "^1.15.0",
|
||||||
"@types/bcryptjs": "^2.4.2",
|
"@types/bcryptjs": "^2.4.2",
|
||||||
"@types/node": "^18.11.18",
|
"@types/node": "^18.11.18",
|
||||||
"@types/nprogress": "0.2.0",
|
"@types/nprogress": "0.2.0",
|
||||||
|
|
@ -56,7 +57,7 @@
|
||||||
"husky": "8.0.3",
|
"husky": "8.0.3",
|
||||||
"is-ci": "3.0.1",
|
"is-ci": "3.0.1",
|
||||||
"prettier": "2.8.4",
|
"prettier": "2.8.4",
|
||||||
"prisma": "^4.9.0",
|
"prisma": "3.9.1",
|
||||||
"release-it": "15.6.0",
|
"release-it": "15.6.0",
|
||||||
"ts-node": "10.9.1",
|
"ts-node": "10.9.1",
|
||||||
"typescript": "^4.8.4"
|
"typescript": "^4.8.4"
|
||||||
|
|
|
||||||
BIN
prisma/dev.db-journal
Normal file
BIN
prisma/dev.db-journal
Normal file
Binary file not shown.
|
|
@ -1,60 +0,0 @@
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "User" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"email" TEXT NOT NULL,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "Password" (
|
|
||||||
"hash" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "TimeEntry" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"description" TEXT NOT NULL,
|
|
||||||
"startTime" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"endTime" TIMESTAMP(3),
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
"projectId" TEXT NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "TimeEntry_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateTable
|
|
||||||
CREATE TABLE "Project" (
|
|
||||||
"id" TEXT NOT NULL,
|
|
||||||
"name" TEXT NOT NULL,
|
|
||||||
"description" TEXT,
|
|
||||||
"color" TEXT NOT NULL,
|
|
||||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
||||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
|
||||||
"userId" TEXT NOT NULL,
|
|
||||||
|
|
||||||
CONSTRAINT "Project_pkey" PRIMARY KEY ("id")
|
|
||||||
);
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
|
||||||
|
|
||||||
-- CreateIndex
|
|
||||||
CREATE UNIQUE INDEX "Password_userId_key" ON "Password"("userId");
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "Password" ADD CONSTRAINT "Password_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "TimeEntry" ADD CONSTRAINT "TimeEntry_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "TimeEntry" ADD CONSTRAINT "TimeEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
||||||
-- AddForeignKey
|
|
||||||
ALTER TABLE "Project" ADD CONSTRAINT "Project_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "TimeEntry" ADD COLUMN "duration" DECIMAL(65,30);
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
/*
|
|
||||||
Warnings:
|
|
||||||
|
|
||||||
- You are about to alter the column `duration` on the `TimeEntry` table. The data in that column could be lost. The data in that column will be cast from `Decimal(65,30)` to `DoublePrecision`.
|
|
||||||
|
|
||||||
*/
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "TimeEntry" ALTER COLUMN "duration" SET DATA TYPE DOUBLE PRECISION;
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "User" ADD COLUMN "admin" BOOLEAN NOT NULL DEFAULT false;
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "User" ADD COLUMN "dateFormat" TEXT NOT NULL DEFAULT 'en-US';
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "User" ALTER COLUMN "dateFormat" SET DEFAULT 'en-GB';
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "User" ADD COLUMN "defaultCurrency" TEXT NOT NULL DEFAULT '€',
|
|
||||||
ADD COLUMN "defaultHourlyRate" DOUBLE PRECISION;
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
/*
|
|
||||||
Warnings:
|
|
||||||
|
|
||||||
- You are about to drop the column `defaultCurrency` on the `User` table. All the data in the column will be lost.
|
|
||||||
|
|
||||||
*/
|
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "User" DROP COLUMN "defaultCurrency",
|
|
||||||
ADD COLUMN "currency" TEXT NOT NULL DEFAULT '€';
|
|
||||||
51
prisma/migrations/20230622073226_init/migration.sql
Normal file
51
prisma/migrations/20230622073226_init/migration.sql
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"admin" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"dateFormat" TEXT NOT NULL DEFAULT 'en-GB',
|
||||||
|
"currency" TEXT NOT NULL DEFAULT '€',
|
||||||
|
"defaultHourlyRate" REAL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Password" (
|
||||||
|
"hash" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "Password_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "TimeEntry" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"startTime" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"endTime" DATETIME,
|
||||||
|
"duration" REAL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
"projectId" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "TimeEntry_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||||
|
CONSTRAINT "TimeEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Project" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"description" TEXT,
|
||||||
|
"color" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" DATETIME NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
CONSTRAINT "Project_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Password_userId_key" ON "Password"("userId");
|
||||||
5
prisma/migrations/20230622084526_settings/migration.sql
Normal file
5
prisma/migrations/20230622084526_settings/migration.sql
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "Settings" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"value" TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
# Please do not edit this file manually
|
# Please do not edit this file manually
|
||||||
# It should be added in your version-control system (i.e. Git)
|
# It should be added in your version-control system (i.e. Git)
|
||||||
provider = "postgresql"
|
provider = "sqlite"
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
datasource db {
|
datasource db {
|
||||||
provider = "postgresql"
|
provider = "sqlite"
|
||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -24,6 +24,11 @@ model User {
|
||||||
projects Project[]
|
projects Project[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model Settings {
|
||||||
|
id String @id
|
||||||
|
value String
|
||||||
|
}
|
||||||
|
|
||||||
model Password {
|
model Password {
|
||||||
hash String
|
hash String
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ import bcrypt from 'bcryptjs';
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
async function seed() {
|
async function seed() {
|
||||||
const email = 'nicola@rawmaterial.it';
|
const email = 'nicola@nzambello.dev';
|
||||||
const adminEmail = 'admin@rawmaterial.it';
|
const adminEmail = 'admin@nzambello.dev';
|
||||||
|
|
||||||
// cleanup the existing database
|
// cleanup the existing database
|
||||||
await prisma.user.delete({ where: { email } }).catch(() => {
|
await prisma.user.delete({ where: { email } }).catch(() => {
|
||||||
|
|
|
||||||
BIN
public/images/00-time-entries-light.png
Normal file
BIN
public/images/00-time-entries-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 342 KiB |
BIN
public/images/01-time-entries-dark.png
Normal file
BIN
public/images/01-time-entries-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 352 KiB |
BIN
public/images/02-new-time-entry.png
Normal file
BIN
public/images/02-new-time-entry.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 240 KiB |
BIN
public/images/03-projects.png
Normal file
BIN
public/images/03-projects.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 395 KiB |
BIN
public/images/04-new-project.png
Normal file
BIN
public/images/04-new-project.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 268 KiB |
BIN
public/images/05-reports.png
Normal file
BIN
public/images/05-reports.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 301 KiB |
19
start.sh
Normal file
19
start.sh
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
set -ex
|
||||||
|
|
||||||
|
# This file is how Fly starts the server (configured in fly.toml). Before starting
|
||||||
|
# the server though, we need to run any prisma migrations that haven't yet been
|
||||||
|
# run, which is why this file exists in the first place.
|
||||||
|
# Learn more: https://community.fly.io/t/sqlite-not-getting-setup-properly/4386
|
||||||
|
|
||||||
|
# allocate swap space
|
||||||
|
# fallocate -l 512M /swapfile
|
||||||
|
# chmod 0600 /swapfile
|
||||||
|
# mkswap /swapfile
|
||||||
|
# echo 10 > /proc/sys/vm/swappiness
|
||||||
|
# swapon /swapfile
|
||||||
|
# echo 1 > /proc/sys/vm/overcommit_memory
|
||||||
|
|
||||||
|
npx prisma migrate deploy
|
||||||
|
yarn start
|
||||||
Loading…
Reference in a new issue