Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b2edbd8ee |
@@ -1,6 +1,6 @@
|
||||
contact_links:
|
||||
- name: Help and support
|
||||
about: Reach out to us on our Forum or GitHub discussions.
|
||||
about: Reach out to us on our Discord server or GitHub discussions.
|
||||
- name: Dedicated support
|
||||
url: mailto:support@plane.so
|
||||
about: Write to us if you'd like dedicated support using Plane
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
# JavaScript/TypeScript dependencies (pnpm monorepo root)
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "javascript"
|
||||
groups:
|
||||
minor-and-patch:
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
|
||||
# Python dependencies
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/apps/api"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "python"
|
||||
groups:
|
||||
minor-and-patch:
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
|
||||
# GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "github-actions"
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
# Docker - API
|
||||
- package-ecosystem: "docker"
|
||||
directory: "/apps/api"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
open-pull-requests-limit: 5
|
||||
labels:
|
||||
- "dependencies"
|
||||
- "docker"
|
||||
@@ -20,20 +20,43 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: ["python", "javascript"]
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||
# Use only 'java' to analyze code written in Java, Kotlin or both
|
||||
# Use only 'javascript' to analyze code written in JavaScript, TypeScript or both
|
||||
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v4
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, Java, or Swift).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v4
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
|
||||
# If the Autobuild fails above, remove it and uncomment the following three lines.
|
||||
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
|
||||
|
||||
# - run: |
|
||||
# echo "Run, Build Application using script"
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v4
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -117,54 +117,16 @@ jobs:
|
||||
path: .turbo
|
||||
key: turbo-${{ runner.os }}-${{ github.event.pull_request.base.sha }}-${{ github.sha }}
|
||||
|
||||
# Lint check - no build dependency, OxLint is a standalone Rust binary
|
||||
check-lint:
|
||||
name: check:lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
if: |
|
||||
github.event.pull_request.draft == false &&
|
||||
github.event.pull_request.requested_reviewers != null
|
||||
env:
|
||||
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha }}
|
||||
TURBO_SCM_HEAD: ${{ github.sha }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 50
|
||||
filter: blob:none
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
|
||||
- name: Enable Corepack and pnpm
|
||||
run: corepack enable pnpm
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- name: Cache pnpm store
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: pnpm-store-${{ runner.os }}-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
pnpm-store-${{ runner.os }}-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run check:lint
|
||||
run: pnpm turbo run check:lint --affected
|
||||
|
||||
# Type check depends on build artifacts
|
||||
check-types:
|
||||
name: check:types
|
||||
# Lint and type checks depend on build artifacts
|
||||
check:
|
||||
name: ${{ matrix.task }}
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
task: [check:lint, check:types]
|
||||
env:
|
||||
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha }}
|
||||
TURBO_SCM_HEAD: ${{ github.sha }}
|
||||
@@ -203,5 +165,5 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run check:types
|
||||
run: pnpm turbo run check:types --affected
|
||||
- name: Run ${{ matrix.task }}
|
||||
run: pnpm turbo run ${{ matrix.task }} --affected
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["react", "typescript", "jsx-a11y", "import", "promise", "unicorn", "oxc"],
|
||||
"categories": {
|
||||
"correctness": "warn",
|
||||
"suspicious": "warn",
|
||||
"perf": "warn"
|
||||
},
|
||||
"env": {
|
||||
"browser": true,
|
||||
"node": true,
|
||||
"es2024": true
|
||||
},
|
||||
"settings": {
|
||||
"react": {
|
||||
"version": "18.3"
|
||||
},
|
||||
"jsx-a11y": {
|
||||
"polymorphicPropName": "as"
|
||||
}
|
||||
},
|
||||
"ignorePatterns": [
|
||||
".cache/**",
|
||||
".next/**",
|
||||
".react-router/**",
|
||||
".storybook/**",
|
||||
".turbo/**",
|
||||
".vite/**",
|
||||
"*.config.{js,mjs,cjs,ts}",
|
||||
"build/**",
|
||||
"coverage/**",
|
||||
"dist/**",
|
||||
"**/public/**",
|
||||
"storybook-static/**"
|
||||
],
|
||||
"rules": {
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/prop-types": "off",
|
||||
"unicorn/filename-case": "off",
|
||||
"unicorn/no-null": "off",
|
||||
"unicorn/prevent-abbreviations": "off",
|
||||
"no-unused-vars": [
|
||||
"warn",
|
||||
{
|
||||
"argsIgnorePattern": "^_",
|
||||
"varsIgnorePattern": "^_",
|
||||
"caughtErrorsIgnorePattern": "^_",
|
||||
"destructuredArrayIgnorePattern": "^_",
|
||||
"ignoreRestSiblings": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,5 @@
|
||||
{
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"sortTailwindcss": {
|
||||
"stylesheet": "packages/tailwind-config/index.css",
|
||||
"functions": ["cn", "clsx", "cva"]
|
||||
},
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["packages/codemods/**/*"],
|
||||
@@ -13,5 +7,9 @@
|
||||
"printWidth": 80
|
||||
}
|
||||
}
|
||||
]
|
||||
],
|
||||
"plugins": ["@prettier/plugin-oxc"],
|
||||
"printWidth": 120,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5"
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
- `pnpm dev` - Start all dev servers (web:3000, admin:3001)
|
||||
- `pnpm build` - Build all packages and apps
|
||||
- `pnpm check` - Run all checks (format, lint, types)
|
||||
- `pnpm check:lint` - OxLint across all packages
|
||||
- `pnpm check:lint` - ESLint across all packages
|
||||
- `pnpm check:types` - TypeScript type checking
|
||||
- `pnpm fix` - Auto-fix format and lint issues
|
||||
- `pnpm turbo run <command> --filter=<package>` - Target specific package/app
|
||||
@@ -15,8 +15,8 @@
|
||||
|
||||
- **Imports**: Use `workspace:*` for internal packages, `catalog:` for external deps
|
||||
- **TypeScript**: Strict mode enabled, all files must be typed
|
||||
- **Formatting**: oxfmt, run `pnpm fix:format`
|
||||
- **Linting**: OxLint with shared `.oxlintrc.json` config
|
||||
- **Formatting**: Prettier with Tailwind plugin, run `pnpm fix:format`
|
||||
- **Linting**: ESLint with shared config, max warnings vary by package
|
||||
- **Naming**: camelCase for variables/functions, PascalCase for components/types
|
||||
- **Error Handling**: Use try-catch with proper error types, log errors appropriately
|
||||
- **State Management**: MobX stores in `packages/shared-state`, reactive patterns
|
||||
|
||||
+2
-2
@@ -91,7 +91,7 @@ If you would like to _implement_ it, an issue with your proposal must be submitt
|
||||
To ensure consistency throughout the source code, please keep these rules in mind as you are working:
|
||||
|
||||
- All features or bug fixes must be tested by one or more specs (unit-tests).
|
||||
- We lint with [OxLint](https://oxc.rs/docs/guide/usage/linter) using the shared `.oxlintrc.json` and format with [oxfmt](https://oxc.rs/docs/guide/usage/formatter) using `.oxfmtrc.json`.
|
||||
- We lint with [ESLint 9](https://eslint.org/docs/latest/) using the shared `eslint.config.mjs` (type-aware via `typescript-eslint`) and format with [Prettier](https://prettier.io/) using `prettier.config.cjs`.
|
||||
|
||||
## Ways to contribute
|
||||
|
||||
@@ -244,4 +244,4 @@ Happy translating! 🌍✨
|
||||
|
||||
## Need help? Questions and suggestions
|
||||
|
||||
Questions, suggestions, and thoughts are most welcome. We can also be reached in our [Forum](https://forum.plane.so).
|
||||
Questions, suggestions, and thoughts are most welcome. We can also be reached in our [Discord Server](https://discord.com/invite/A92xrEGCge).
|
||||
|
||||
@@ -7,9 +7,16 @@
|
||||
</p>
|
||||
<p align="center"><b>Modern project management for all teams</b></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://discord.com/invite/A92xrEGCge">
|
||||
<img alt="Discord online members" src="https://img.shields.io/discord/1031547764020084846?color=5865F2&label=Discord&style=for-the-badge" />
|
||||
</a>
|
||||
<img alt="Commit activity per month" src="https://img.shields.io/github/commit-activity/m/makeplane/plane?style=for-the-badge" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://plane.so/"><b>Website</b></a> •
|
||||
<a href="https://forum.plane.so"><b>Forum</b></a> •
|
||||
<a href="https://github.com/makeplane/plane/releases"><b>Releases</b></a> •
|
||||
<a href="https://twitter.com/planepowers"><b>Twitter</b></a> •
|
||||
<a href="https://docs.plane.so/"><b>Documentation</b></a>
|
||||
</p>
|
||||
@@ -26,7 +33,7 @@
|
||||
|
||||
Meet [Plane](https://plane.so/), an open-source project management tool to track issues, run ~sprints~ cycles, and manage product roadmaps without the chaos of managing the tool itself. 🧘♀️
|
||||
|
||||
> Plane is evolving every day. Your suggestions, ideas, and reported bugs help us immensely. Do not hesitate to join in the conversation on [Forum](https://forum.plane.so) or raise a GitHub issue. We read everything and respond to most.
|
||||
> Plane is evolving every day. Your suggestions, ideas, and reported bugs help us immensely. Do not hesitate to join in the conversation on [Discord](https://discord.com/invite/A92xrEGCge) or raise a GitHub issue. We read everything and respond to most.
|
||||
|
||||
## 🚀 Installation
|
||||
|
||||
@@ -129,7 +136,7 @@ Explore Plane's [product documentation](https://docs.plane.so/) and [developer d
|
||||
|
||||
## ❤️ Community
|
||||
|
||||
Join the Plane community on [GitHub Discussions](https://github.com/orgs/makeplane/discussions) and our [Forum](https://forum.plane.so). We follow a [Code of conduct](https://github.com/makeplane/plane/blob/master/CODE_OF_CONDUCT.md) in all our community channels.
|
||||
Join the Plane community on [GitHub Discussions](https://github.com/orgs/makeplane/discussions) and our [Discord server](https://discord.com/invite/A92xrEGCge). We follow a [Code of conduct](https://github.com/makeplane/plane/blob/master/CODE_OF_CONDUCT.md) in all our community channels.
|
||||
|
||||
Feel free to ask questions, report bugs, participate in discussions, share ideas, request features, or showcase your projects. We’d love to hear from you!
|
||||
|
||||
@@ -145,7 +152,7 @@ There are many ways you can contribute to Plane:
|
||||
|
||||
- Report [bugs](https://github.com/makeplane/plane/issues/new?assignees=srinivaspendem%2Cpushya22&labels=%F0%9F%90%9Bbug&projects=&template=--bug-report.yaml&title=%5Bbug%5D%3A+) or submit [feature requests](https://github.com/makeplane/plane/issues/new?assignees=srinivaspendem%2Cpushya22&labels=%E2%9C%A8feature&projects=&template=--feature-request.yaml&title=%5Bfeature%5D%3A+).
|
||||
- Review the [documentation](https://docs.plane.so/) and submit [pull requests](https://github.com/makeplane/docs) to improve it—whether it's fixing typos or adding new content.
|
||||
- Talk or write about Plane or any other ecosystem integration and [let us know](https://forum.plane.so)!
|
||||
- Talk or write about Plane or any other ecosystem integration and [let us know](https://discord.com/invite/A92xrEGCge)!
|
||||
- Show your support by upvoting [popular feature requests](https://github.com/makeplane/plane/issues).
|
||||
|
||||
Please read [CONTRIBUTING.md](https://github.com/makeplane/plane/blob/master/CONTRIBUTING.md) for details on the process for submitting pull requests to us.
|
||||
|
||||
@@ -13,7 +13,7 @@ RUN corepack enable pnpm
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
RUN pnpm add -g turbo@2.8.12
|
||||
RUN pnpm add -g turbo@2.6.3
|
||||
|
||||
COPY . .
|
||||
|
||||
|
||||
@@ -120,16 +120,16 @@ export function InstanceAIForm(props: IInstanceAIForm) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-start gap-4">
|
||||
<div className="flex flex-col gap-4 items-start">
|
||||
<Button variant="primary" size="lg" onClick={handleSubmit(onSubmit)} loading={isSubmitting}>
|
||||
{isSubmitting ? "Saving" : "Save changes"}
|
||||
</Button>
|
||||
|
||||
<div className="relative inline-flex items-center gap-1.5 rounded-sm border border-accent-subtle bg-accent-subtle px-4 py-2 text-caption-sm-regular text-accent-secondary">
|
||||
<div className="relative inline-flex items-center gap-1.5 rounded-sm border border-accent-subtle bg-accent-subtle px-4 py-2 text-caption-sm-regular text-accent-secondary ">
|
||||
<Lightbulb className="size-4" />
|
||||
<div>
|
||||
If you have a preferred AI models vendor, please get in{" "}
|
||||
<a className="font-medium underline" href="https://plane.so/contact">
|
||||
<a className="underline font-medium" href="https://plane.so/contact">
|
||||
touch with us.
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -34,7 +34,7 @@ const InstanceAIPage = observer(function InstanceAIPage(_props: Route.ComponentP
|
||||
) : (
|
||||
<Loader className="space-y-8">
|
||||
<Loader.Item height="50px" width="40%" />
|
||||
<div className="grid w-2/3 grid-cols-2 gap-x-8 gap-y-4">
|
||||
<div className="w-2/3 grid grid-cols-2 gap-x-8 gap-y-4">
|
||||
<Loader.Item height="50px" />
|
||||
<Loader.Item height="50px" />
|
||||
</div>
|
||||
|
||||
@@ -176,8 +176,8 @@ export function InstanceGiteaConfigForm(props: Props) {
|
||||
handleClose={() => setIsDiscardChangesModalOpen(false)}
|
||||
/>
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="grid w-full grid-cols-2 gap-x-12 gap-y-8">
|
||||
<div className="col-span-2 flex flex-col gap-y-4 pt-1 md:col-span-1">
|
||||
<div className="grid grid-cols-2 gap-x-12 gap-y-8 w-full">
|
||||
<div className="flex flex-col gap-y-4 col-span-2 md:col-span-1 pt-1">
|
||||
<div className="pt-2.5 text-18 font-medium">Gitea-provided details for Plane</div>
|
||||
{GITEA_FORM_FIELDS.map((field) => (
|
||||
<ControllerInput
|
||||
@@ -211,7 +211,7 @@ export function InstanceGiteaConfigForm(props: Props) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 md:col-span-1">
|
||||
<div className="flex flex-col gap-y-4 rounded-lg bg-layer-1 px-6 pt-1.5 pb-4">
|
||||
<div className="flex flex-col gap-y-4 px-6 pt-1.5 pb-4 bg-layer-1 rounded-lg">
|
||||
<div className="pt-2 text-18 font-medium">Plane-provided details for Gitea</div>
|
||||
{GITEA_SERVICE_FIELD.map((field) => (
|
||||
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
|
||||
|
||||
@@ -197,8 +197,8 @@ export function InstanceGithubConfigForm(props: Props) {
|
||||
handleClose={() => setIsDiscardChangesModalOpen(false)}
|
||||
/>
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="grid w-full grid-cols-2 gap-x-12 gap-y-8">
|
||||
<div className="col-span-2 flex flex-col gap-y-4 pt-1 md:col-span-1">
|
||||
<div className="grid grid-cols-2 gap-x-12 gap-y-8 w-full">
|
||||
<div className="flex flex-col gap-y-4 col-span-2 md:col-span-1 pt-1">
|
||||
<div className="pt-2.5 text-18 font-medium">GitHub-provided details for Plane</div>
|
||||
{GITHUB_FORM_FIELDS.map((field) => (
|
||||
<ControllerInput
|
||||
@@ -231,24 +231,24 @@ export function InstanceGithubConfigForm(props: Props) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 flex flex-col gap-y-6 md:col-span-1">
|
||||
<div className="col-span-2 md:col-span-1 flex flex-col gap-y-6">
|
||||
<div className="pt-2 text-18 font-medium">Plane-provided details for GitHub</div>
|
||||
|
||||
<div className="flex flex-col gap-y-4">
|
||||
{/* common service details */}
|
||||
<div className="flex flex-col gap-y-4 rounded-lg bg-layer-1 px-6 py-4">
|
||||
<div className="flex flex-col gap-y-4 px-6 py-4 bg-layer-1 rounded-lg">
|
||||
{GITHUB_COMMON_SERVICE_DETAILS.map((field) => (
|
||||
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* web service details */}
|
||||
<div className="flex flex-col overflow-hidden rounded-lg">
|
||||
<div className="flex items-center gap-x-3 bg-layer-3 px-6 py-3 text-11 font-medium text-secondary uppercase">
|
||||
<Monitor className="h-3 w-3" />
|
||||
<div className="flex flex-col rounded-lg overflow-hidden">
|
||||
<div className="px-6 py-3 bg-layer-3 font-medium text-11 uppercase flex items-center gap-x-3 text-secondary">
|
||||
<Monitor className="w-3 h-3" />
|
||||
Web
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-4 bg-layer-1 px-6 py-4">
|
||||
<div className="px-6 py-4 flex flex-col gap-y-4 bg-layer-1">
|
||||
{GITHUB_SERVICE_DETAILS.map((field) => (
|
||||
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
|
||||
))}
|
||||
|
||||
@@ -180,8 +180,8 @@ export function InstanceGitlabConfigForm(props: Props) {
|
||||
handleClose={() => setIsDiscardChangesModalOpen(false)}
|
||||
/>
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="grid w-full grid-cols-2 gap-x-12 gap-y-8">
|
||||
<div className="col-span-2 flex flex-col gap-y-4 pt-1 md:col-span-1">
|
||||
<div className="grid grid-cols-2 gap-x-12 gap-y-8 w-full">
|
||||
<div className="flex flex-col gap-y-4 col-span-2 md:col-span-1 pt-1">
|
||||
<div className="pt-2.5 text-18 font-medium">GitLab-provided details for Plane</div>
|
||||
{GITLAB_FORM_FIELDS.map((field) => (
|
||||
<ControllerInput
|
||||
@@ -215,7 +215,7 @@ export function InstanceGitlabConfigForm(props: Props) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 md:col-span-1">
|
||||
<div className="flex flex-col gap-y-4 rounded-lg bg-layer-3 px-6 pt-1.5 pb-4">
|
||||
<div className="flex flex-col gap-y-4 px-6 pt-1.5 pb-4 bg-layer-3 rounded-lg">
|
||||
<div className="pt-2 text-18 font-medium">Plane-provided details for GitLab</div>
|
||||
{GITLAB_SERVICE_FIELD.map((field) => (
|
||||
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
|
||||
|
||||
@@ -185,8 +185,8 @@ export function InstanceGoogleConfigForm(props: Props) {
|
||||
handleClose={() => setIsDiscardChangesModalOpen(false)}
|
||||
/>
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="grid w-full grid-cols-2 gap-x-12 gap-y-8">
|
||||
<div className="col-span-2 flex flex-col gap-y-4 pt-1 md:col-span-1">
|
||||
<div className="grid grid-cols-2 gap-x-12 gap-y-8 w-full">
|
||||
<div className="flex flex-col gap-y-4 col-span-2 md:col-span-1 pt-1">
|
||||
<div className="pt-2.5 text-18 font-medium">Google-provided details for Plane</div>
|
||||
{GOOGLE_FORM_FIELDS.map((field) => (
|
||||
<ControllerInput
|
||||
@@ -219,24 +219,24 @@ export function InstanceGoogleConfigForm(props: Props) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 flex flex-col gap-y-6 md:col-span-1">
|
||||
<div className="col-span-2 md:col-span-1 flex flex-col gap-y-6">
|
||||
<div className="pt-2 text-18 font-medium">Plane-provided details for Google</div>
|
||||
|
||||
<div className="flex flex-col gap-y-4">
|
||||
{/* common service details */}
|
||||
<div className="flex flex-col gap-y-4 rounded-lg bg-layer-1 px-6 py-4">
|
||||
<div className="flex flex-col gap-y-4 px-6 py-4 bg-layer-1 rounded-lg">
|
||||
{GOOGLE_COMMON_SERVICE_DETAILS.map((field) => (
|
||||
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* web service details */}
|
||||
<div className="flex flex-col overflow-hidden rounded-lg">
|
||||
<div className="flex items-center gap-x-3 bg-layer-3 px-6 py-3 text-11 font-medium text-secondary uppercase">
|
||||
<Monitor className="h-3 w-3" />
|
||||
<div className="flex flex-col rounded-lg overflow-hidden">
|
||||
<div className="px-6 py-3 bg-layer-3 font-medium text-11 uppercase flex items-center gap-x-3 text-secondary">
|
||||
<Monitor className="w-3 h-3" />
|
||||
Web
|
||||
</div>
|
||||
<div className="flex flex-col gap-y-4 bg-layer-1 px-6 py-4">
|
||||
<div className="px-6 py-4 flex flex-col gap-y-4 bg-layer-1">
|
||||
{GOOGLE_SERVICE_DETAILS.map((field) => (
|
||||
<CopyField key={field.key} label={field.label} url={field.url} description={field.description} />
|
||||
))}
|
||||
|
||||
@@ -117,11 +117,11 @@ const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(
|
||||
>
|
||||
{formattedConfig ? (
|
||||
<div className="space-y-3">
|
||||
<div className={cn("flex w-full items-center gap-14 rounded-sm")}>
|
||||
<div className={cn("w-full flex items-center gap-14 rounded-sm")}>
|
||||
<div className="flex grow items-center gap-4">
|
||||
<div className="grow">
|
||||
<div className="pb-1 text-16 font-medium">Allow anyone to sign up even without an invite</div>
|
||||
<div className={cn("text-11 leading-5 font-regular text-tertiary")}>
|
||||
<div className="text-16 font-medium pb-1">Allow anyone to sign up even without an invite</div>
|
||||
<div className={cn("font-regular leading-5 text-tertiary text-11")}>
|
||||
Toggling this off will only let users sign up when they are invited.
|
||||
</div>
|
||||
</div>
|
||||
@@ -143,7 +143,7 @@ const InstanceAuthenticationPage = observer(function InstanceAuthenticationPage(
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-lg pt-6 font-medium">Available authentication modes</div>
|
||||
<div className="text-lg font-medium pt-6">Available authentication modes</div>
|
||||
{authenticationModes.map((method) => (
|
||||
<AuthenticationMethodCard
|
||||
key={method.key}
|
||||
|
||||
@@ -179,7 +179,7 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
|
||||
</CustomSelect>
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-6 flex flex-col gap-6 border-t border-subtle pt-4">
|
||||
<div className="flex flex-col gap-6 my-6 pt-4 border-t border-subtle">
|
||||
<div className="flex w-full max-w-xl flex-col gap-y-10 px-1">
|
||||
<div className="mr-8 flex items-center gap-10 pt-4">
|
||||
<div className="grow">
|
||||
@@ -207,7 +207,7 @@ export function InstanceEmailForm(props: IInstanceEmailForm) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex max-w-4xl items-center gap-4 py-1">
|
||||
<div className="flex max-w-4xl items-center py-1 gap-4">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
|
||||
@@ -91,8 +91,8 @@ export function SendTestEmailModal(props: Props) {
|
||||
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative w-full transform rounded-lg bg-surface-1 p-5 px-4 text-left shadow-raised-200 transition-all sm:max-w-xl">
|
||||
<h3 className="text-16 leading-6 font-medium text-primary">
|
||||
<Dialog.Panel className="relative transform rounded-lg bg-surface-1 p-5 px-4 text-left shadow-raised-200 transition-all w-full sm:max-w-xl">
|
||||
<h3 className="text-16 font-medium leading-6 text-primary">
|
||||
{sendEmailStep === ESendEmailSteps.SEND_EMAIL
|
||||
? "Send test email"
|
||||
: sendEmailStep === ESendEmailSteps.SUCCESS
|
||||
@@ -121,7 +121,7 @@ export function SendTestEmailModal(props: Props) {
|
||||
</div>
|
||||
)}
|
||||
{sendEmailStep === ESendEmailSteps.FAILED && <div className="text-13">{error}</div>}
|
||||
<div className="mt-5 flex items-center justify-end gap-2">
|
||||
<div className="flex items-center gap-2 justify-end mt-5">
|
||||
<Button variant="secondary" size="lg" onClick={handleClose} tabIndex={2}>
|
||||
{sendEmailStep === ESendEmailSteps.SEND_EMAIL ? "Cancel" : "Close"}
|
||||
</Button>
|
||||
|
||||
@@ -112,18 +112,18 @@ export const GeneralConfigurationForm = observer(function GeneralConfigurationFo
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="border-b border-subtle pb-1.5 text-16 font-medium text-primary">Chat + telemetry</div>
|
||||
<div className="text-16 font-medium text-primary pb-1.5 border-b border-subtle">Chat + telemetry</div>
|
||||
<IntercomConfig isTelemetryEnabled={watch("is_telemetry_enabled") ?? false} />
|
||||
<div className="flex items-center gap-14">
|
||||
<div className="flex grow items-center gap-4">
|
||||
<div className="grow flex items-center gap-4">
|
||||
<div className="shrink-0">
|
||||
<div className="flex size-11 items-center justify-center rounded-lg bg-layer-1">
|
||||
<div className="flex items-center justify-center size-11 bg-layer-1 rounded-lg">
|
||||
<Telescope className="size-5 text-tertiary" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="text-13 leading-5 font-medium text-primary">Let Plane collect anonymous usage data</div>
|
||||
<div className="text-11 leading-5 font-regular text-tertiary">
|
||||
<div className="text-13 font-medium text-primary leading-5">Let Plane collect anonymous usage data</div>
|
||||
<div className="text-11 font-regular text-tertiary leading-5">
|
||||
No PII is collected.This anonymized data is used to understand how you use Plane and build new features
|
||||
in line with{" "}
|
||||
<a
|
||||
|
||||
@@ -56,16 +56,16 @@ export const IntercomConfig = observer(function IntercomConfig(props: TIntercomC
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center gap-14">
|
||||
<div className="flex grow items-center gap-4">
|
||||
<div className="grow flex items-center gap-4">
|
||||
<div className="shrink-0">
|
||||
<div className="flex size-11 items-center justify-center rounded-lg bg-layer-1">
|
||||
<MessageSquare className="size-5 p-0.5 text-tertiary" />
|
||||
<div className="flex items-center justify-center size-11 bg-layer-1 rounded-lg">
|
||||
<MessageSquare className="size-5 text-tertiary p-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grow">
|
||||
<div className="text-13 leading-5 font-medium text-primary">Chat with us</div>
|
||||
<div className="text-11 leading-5 font-regular text-tertiary">
|
||||
<div className="text-13 font-medium text-primary leading-5">Chat with us</div>
|
||||
<div className="text-11 font-regular text-tertiary leading-5">
|
||||
Let your users chat with us via Intercom or another service. Toggling Telemetry off turns this off
|
||||
automatically.
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,7 @@ function AdminLayout(_props: Route.ComponentProps) {
|
||||
<AdminSidebar />
|
||||
<main className="relative flex h-full w-full flex-col overflow-hidden bg-surface-1">
|
||||
<AdminHeader />
|
||||
<div className="vertical-scrollbar scrollbar-md h-full w-full overflow-hidden overflow-y-scroll">
|
||||
<div className="h-full w-full overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-md">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -39,14 +39,14 @@ export const AdminSidebarDropdown = observer(function AdminSidebarDropdown() {
|
||||
const getSidebarMenuItems = () => (
|
||||
<Menu.Items
|
||||
className={cn(
|
||||
"shadow-lg absolute left-0 z-20 mt-1.5 flex w-52 flex-col divide-y divide-subtle rounded-md border border-subtle bg-surface-1 px-1 py-2 text-11 outline-none",
|
||||
"absolute left-0 z-20 mt-1.5 flex w-52 flex-col divide-y divide-subtle rounded-md border border-subtle bg-surface-1 px-1 py-2 text-11 shadow-lg outline-none",
|
||||
{
|
||||
"left-4": isSidebarCollapsed,
|
||||
}
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col gap-2.5 pb-2">
|
||||
<span className="truncate px-2 text-secondary">{currentUser?.email}</span>
|
||||
<span className="px-2 text-secondary truncate">{currentUser?.email}</span>
|
||||
</div>
|
||||
<div className="py-2">
|
||||
<Menu.Item
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Link from "next/link";
|
||||
import { HelpCircle, MessageSquare, MoveLeft } from "lucide-react";
|
||||
import { HelpCircle, MoveLeft } from "lucide-react";
|
||||
import { Transition } from "@headlessui/react";
|
||||
import { WEB_BASE_URL } from "@plane/constants";
|
||||
// plane internal packages
|
||||
import { GithubIcon, NewTabIcon, PageIcon } from "@plane/propel/icons";
|
||||
import { DiscordIcon, GithubIcon, NewTabIcon, PageIcon } from "@plane/propel/icons";
|
||||
import { Tooltip } from "@plane/propel/tooltip";
|
||||
import { cn } from "@plane/utils";
|
||||
// hooks
|
||||
@@ -25,9 +25,9 @@ const helpOptions = [
|
||||
Icon: PageIcon,
|
||||
},
|
||||
{
|
||||
name: "Join our Forum",
|
||||
href: "https://forum.plane.so",
|
||||
Icon: MessageSquare,
|
||||
name: "Join our Discord",
|
||||
href: "https://discord.com/invite/A92xrEGCge",
|
||||
Icon: DiscordIcon,
|
||||
},
|
||||
{
|
||||
name: "Report a bug",
|
||||
@@ -50,9 +50,9 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-14 w-full flex-shrink-0 items-center justify-between gap-1 self-baseline border-t border-subtle bg-surface-1 px-4",
|
||||
"flex w-full items-center justify-between gap-1 self-baseline border-t border-subtle bg-surface-1 px-4 h-14 flex-shrink-0",
|
||||
{
|
||||
"h-auto flex-col py-1.5": isSidebarCollapsed,
|
||||
"flex-col h-auto py-1.5": isSidebarCollapsed,
|
||||
}
|
||||
)}
|
||||
>
|
||||
@@ -60,7 +60,7 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection
|
||||
<Tooltip tooltipContent="Redirect to Plane" position="right" className="ml-4" disabled={!isSidebarCollapsed}>
|
||||
<a
|
||||
href={redirectionLink}
|
||||
className={`relative flex items-center gap-1 rounded-sm bg-layer-1 px-2 py-1 text-body-xs-medium whitespace-nowrap text-secondary`}
|
||||
className={`relative px-2 py-1 flex items-center gap-1 rounded-sm bg-layer-1 text-body-xs-medium text-secondary whitespace-nowrap`}
|
||||
>
|
||||
<NewTabIcon width={14} height={14} />
|
||||
{!isSidebarCollapsed && "Redirect to Plane"}
|
||||
@@ -101,9 +101,9 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
className={`absolute bottom-2 z-[15] min-w-[10rem] ${
|
||||
className={`absolute bottom-2 min-w-[10rem] z-[15] ${
|
||||
isSidebarCollapsed ? "left-full" : "-left-[75px]"
|
||||
} divide-y divide-subtle-1 rounded-sm bg-surface-1 p-1 whitespace-nowrap shadow-raised-100`}
|
||||
} divide-y divide-subtle-1 whitespace-nowrap rounded-sm bg-surface-1 p-1 shadow-raised-100`}
|
||||
ref={helpOptionsRef}
|
||||
>
|
||||
<div className="space-y-1 pb-2">
|
||||
@@ -134,7 +134,7 @@ export const AdminSidebarHelpSection = observer(function AdminSidebarHelpSection
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="px-2 pt-2 pb-1 text-10">Version: v{instance?.current_version}</div>
|
||||
<div className="px-2 pb-1 pt-2 text-10">Version: v{instance?.current_version}</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
@@ -29,7 +29,7 @@ export const AdminSidebarMenu = observer(function AdminSidebarMenu() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="vertical-scrollbar flex scrollbar-sm h-full w-full flex-col gap-2.5 overflow-y-scroll px-4 py-4">
|
||||
<div className="flex h-full w-full flex-col gap-2.5 overflow-y-scroll vertical-scrollbar scrollbar-sm px-4 py-4">
|
||||
{sidebarMenu.map((item, index) => {
|
||||
const isActive = item.href === pathName || pathName?.includes(item.href);
|
||||
return (
|
||||
@@ -38,9 +38,9 @@ export const AdminSidebarMenu = observer(function AdminSidebarMenu() {
|
||||
<Tooltip tooltipContent={item.name} position="right" className="ml-2" disabled={!isSidebarCollapsed}>
|
||||
<div
|
||||
className={cn(
|
||||
"group flex w-full items-center gap-3 rounded-md px-3 py-2 transition-colors outline-none",
|
||||
"group flex w-full items-center gap-3 rounded-md px-3 py-2 outline-none transition-colors",
|
||||
{
|
||||
"!bg-layer-transparent-active text-primary": isActive,
|
||||
"text-primary !bg-layer-transparent-active": isActive,
|
||||
"text-secondary hover:bg-layer-transparent-hover active:bg-layer-transparent-active": !isActive,
|
||||
},
|
||||
isSidebarCollapsed ? "justify-center" : "w-[260px]"
|
||||
@@ -48,7 +48,7 @@ export const AdminSidebarMenu = observer(function AdminSidebarMenu() {
|
||||
>
|
||||
{<item.Icon className="h-4 w-4 flex-shrink-0" />}
|
||||
{!isSidebarCollapsed && (
|
||||
<div className="w-full">
|
||||
<div className="w-full ">
|
||||
<div className={cn(`text-body-xs-medium transition-colors`)}>{item.name}</div>
|
||||
<div className={cn(`text-caption-sm-regular transition-colors`)}>{item.description}</div>
|
||||
</div>
|
||||
|
||||
@@ -44,7 +44,13 @@ export const AdminSidebar = observer(function AdminSidebar() {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`fixed inset-y-0 z-20 flex h-full flex-shrink-0 flex-grow-0 flex-col border-r border-subtle bg-surface-1 duration-300 md:relative ${isSidebarCollapsed ? "-ml-[290px]" : ""} sm:${isSidebarCollapsed ? "-ml-[290px]" : ""} md:ml-0 ${isSidebarCollapsed ? "w-[70px]" : "w-[290px]"} lg:ml-0 ${isSidebarCollapsed ? "w-[70px]" : "w-[290px]"} `}
|
||||
className={`inset-y-0 z-20 flex h-full flex-shrink-0 flex-grow-0 flex-col border-r border-subtle bg-surface-1 duration-300
|
||||
fixed md:relative
|
||||
${isSidebarCollapsed ? "-ml-[290px]" : ""}
|
||||
sm:${isSidebarCollapsed ? "-ml-[290px]" : ""}
|
||||
md:ml-0 ${isSidebarCollapsed ? "w-[70px]" : "w-[290px]"}
|
||||
lg:ml-0 ${isSidebarCollapsed ? "w-[70px]" : "w-[290px]"}
|
||||
`}
|
||||
>
|
||||
<div ref={ref} className="flex h-full w-full flex-1 flex-col">
|
||||
<AdminSidebarDropdown />
|
||||
|
||||
@@ -14,7 +14,6 @@ import { Button, getButtonStyling } from "@plane/propel/button";
|
||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||
import { InstanceWorkspaceService } from "@plane/services";
|
||||
import type { IWorkspace } from "@plane/types";
|
||||
import { validateSlug, validateWorkspaceName } from "@plane/utils";
|
||||
// components
|
||||
import { CustomSelect, Input } from "@plane/ui";
|
||||
// hooks
|
||||
@@ -97,7 +96,14 @@ export function WorkspaceCreateForm() {
|
||||
control={control}
|
||||
name="name"
|
||||
rules={{
|
||||
validate: (value) => validateWorkspaceName(value, true),
|
||||
required: "This is a required field.",
|
||||
validate: (value) =>
|
||||
/^[\w\s-]*$/.test(value) ||
|
||||
`Workspaces names can contain only (" "), ( - ), ( _ ) and alphanumeric characters.`,
|
||||
maxLength: {
|
||||
value: 80,
|
||||
message: "Limit your name to 80 characters.",
|
||||
},
|
||||
}}
|
||||
render={({ field: { value, ref, onChange } }) => (
|
||||
<Input
|
||||
@@ -123,13 +129,17 @@ export function WorkspaceCreateForm() {
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-13 text-tertiary">Set your workspace's URL</h4>
|
||||
<div className="flex w-full items-center gap-0.5 rounded-md border-[0.5px] border-subtle px-3">
|
||||
<span className="text-13 whitespace-nowrap text-secondary">{workspaceBaseURL}</span>
|
||||
<div className="flex gap-0.5 w-full items-center rounded-md border-[0.5px] border-subtle px-3">
|
||||
<span className="whitespace-nowrap text-13 text-secondary">{workspaceBaseURL}</span>
|
||||
<Controller
|
||||
control={control}
|
||||
name="slug"
|
||||
rules={{
|
||||
validate: (value) => validateSlug(value),
|
||||
required: "The URL is a required field.",
|
||||
maxLength: {
|
||||
value: 48,
|
||||
message: "Limit your URL to 48 characters.",
|
||||
},
|
||||
}}
|
||||
render={({ field: { onChange, value, ref } }) => (
|
||||
<Input
|
||||
@@ -188,7 +198,7 @@ export function WorkspaceCreateForm() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex max-w-4xl items-center gap-4 py-1">
|
||||
<div className="flex max-w-4xl items-center py-1 gap-4">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="lg"
|
||||
|
||||
@@ -83,11 +83,11 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{formattedConfig ? (
|
||||
<div className={cn("flex w-full items-center gap-14 rounded-sm")}>
|
||||
<div className={cn("w-full flex items-center gap-14 rounded-sm")}>
|
||||
<div className="flex grow items-center gap-4">
|
||||
<div className="grow">
|
||||
<div className="pb-1 text-16 font-medium">Prevent anyone else from creating a workspace.</div>
|
||||
<div className={cn("text-11 leading-5 font-regular text-tertiary")}>
|
||||
<div className="text-16 font-medium pb-1">Prevent anyone else from creating a workspace.</div>
|
||||
<div className={cn("font-regular leading-5 text-tertiary text-11")}>
|
||||
Toggling this on will let only you create workspaces. You will have to invite users to new workspaces.
|
||||
</div>
|
||||
</div>
|
||||
@@ -116,15 +116,15 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props
|
||||
)}
|
||||
{workspaceLoader !== "init-loader" ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2 pt-6">
|
||||
<div className="pt-6 flex items-center justify-between gap-2">
|
||||
<div className="flex flex-col items-start gap-x-2">
|
||||
<div className="flex items-center gap-2 text-16 font-medium">
|
||||
All workspaces on this instance <span className="text-tertiary">• {workspaceIds.length}</span>
|
||||
{workspaceLoader && ["mutation", "pagination"].includes(workspaceLoader) && (
|
||||
<LoaderIcon className="h-4 w-4 animate-spin" />
|
||||
<LoaderIcon className="w-4 h-4 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
<div className={cn("text-11 leading-5 font-regular text-tertiary")}>
|
||||
<div className={cn("font-regular leading-5 text-tertiary text-11")}>
|
||||
You can't yet delete workspaces and you can only go to the workspace if you are an Admin or a
|
||||
Member.
|
||||
</div>
|
||||
@@ -149,7 +149,7 @@ const WorkspaceManagementPage = observer(function WorkspaceManagementPage(_props
|
||||
disabled={workspaceLoader === "pagination"}
|
||||
>
|
||||
Load more
|
||||
{workspaceLoader === "pagination" && <LoaderIcon className="h-3 w-3 animate-spin" />}
|
||||
{workspaceLoader === "pagination" && <LoaderIcon className="w-3 h-3 animate-spin" />}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -20,16 +20,16 @@ export function AuthBanner(props: TAuthBanner) {
|
||||
|
||||
if (!bannerData) return <></>;
|
||||
return (
|
||||
<div className="relative flex items-center gap-2 rounded-md border border-accent-strong/50 bg-accent-primary/10 p-2">
|
||||
<div className="relative flex h-4 w-4 flex-shrink-0 items-center justify-center">
|
||||
<div className="relative flex items-center p-2 rounded-md gap-2 border border-accent-strong/50 bg-accent-primary/10">
|
||||
<div className="w-4 h-4 flex-shrink-0 relative flex justify-center items-center">
|
||||
<Info size={16} className="text-accent-primary" />
|
||||
</div>
|
||||
<div className="w-full text-13 font-medium text-accent-primary">{bannerData?.message}</div>
|
||||
<div
|
||||
className="relative ml-auto flex h-6 w-6 cursor-pointer items-center justify-center rounded-xs text-accent-primary transition-all hover:bg-accent-primary/20"
|
||||
className="relative ml-auto w-6 h-6 rounded-xs flex justify-center items-center transition-all cursor-pointer hover:bg-accent-primary/20 text-accent-primary"
|
||||
onClick={() => handleBannerData && handleBannerData(undefined)}
|
||||
>
|
||||
<CloseIcon className="h-4 w-4 flex-shrink-0" />
|
||||
<CloseIcon className="w-4 h-4 flex-shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { PlaneLockup } from "@plane/propel/icons";
|
||||
|
||||
export function AuthHeader() {
|
||||
return (
|
||||
<div className="sticky top-0 flex w-full flex-shrink-0 items-center justify-between gap-6">
|
||||
<div className="flex items-center justify-between gap-6 w-full flex-shrink-0 sticky top-0">
|
||||
<Link href="/">
|
||||
<PlaneLockup height={20} width={95} className="text-primary" />
|
||||
</Link>
|
||||
|
||||
@@ -50,7 +50,7 @@ const errorCodeMessages: {
|
||||
message: () => (
|
||||
<div>
|
||||
Admin user already exists.
|
||||
<Link className="font-medium underline underline-offset-4 transition-all hover:font-bold" href={`/admin`}>
|
||||
<Link className="underline underline-offset-4 font-medium hover:font-bold transition-all" href={`/admin`}>
|
||||
Sign In
|
||||
</Link>
|
||||
now.
|
||||
@@ -62,7 +62,7 @@ const errorCodeMessages: {
|
||||
message: () => (
|
||||
<div>
|
||||
Admin user does not exist.
|
||||
<Link className="font-medium underline underline-offset-4 transition-all hover:font-bold" href={`/admin`}>
|
||||
<Link className="underline underline-offset-4 font-medium hover:font-bold transition-all" href={`/admin`}>
|
||||
Sign In
|
||||
</Link>
|
||||
now.
|
||||
|
||||
@@ -22,7 +22,7 @@ function RootLayout() {
|
||||
}, [replace, isUserLoggedIn]);
|
||||
|
||||
return (
|
||||
<div className="relative z-10 flex h-screen w-screen flex-col items-center overflow-hidden overflow-y-auto bg-surface-1 px-8 pt-6 pb-10">
|
||||
<div className="relative z-10 flex flex-col items-center w-screen h-screen overflow-hidden overflow-y-auto pt-6 pb-10 px-8 bg-surface-1">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -22,7 +22,7 @@ function HomePage() {
|
||||
// if instance is not fetched, show loading
|
||||
if (!instance && !error) {
|
||||
return (
|
||||
<div className="flex h-screen w-full items-center justify-center">
|
||||
<div className="flex items-center justify-center h-screen w-full">
|
||||
<LogoSpinner />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -111,8 +111,8 @@ export function InstanceSignInForm() {
|
||||
return (
|
||||
<>
|
||||
<AuthHeader />
|
||||
<div className="mt-10 flex w-full flex-grow flex-col items-center justify-center py-6">
|
||||
<div className="relative flex w-full max-w-[22.5rem] flex-col gap-6">
|
||||
<div className="flex flex-col justify-center items-center flex-grow w-full py-6 mt-10">
|
||||
<div className="relative flex flex-col gap-6 max-w-[22.5rem] w-full">
|
||||
<FormHeader
|
||||
heading="Manage your Plane instance"
|
||||
subHeading="Configure instance-wide settings to secure your instance"
|
||||
@@ -134,7 +134,7 @@ export function InstanceSignInForm() {
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
|
||||
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-13 font-medium text-tertiary" htmlFor="email">
|
||||
<label className="text-13 text-tertiary font-medium" htmlFor="email">
|
||||
Email <span className="text-danger-primary">*</span>
|
||||
</label>
|
||||
<Input
|
||||
@@ -146,13 +146,13 @@ export function InstanceSignInForm() {
|
||||
placeholder="name@company.com"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleFormChange("email", e.target.value)}
|
||||
autoComplete="off"
|
||||
autoComplete="on"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-13 font-medium text-tertiary" htmlFor="password">
|
||||
<label className="text-13 text-tertiary font-medium" htmlFor="password">
|
||||
Password <span className="text-danger-primary">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
@@ -165,12 +165,12 @@ export function InstanceSignInForm() {
|
||||
placeholder="Enter your password"
|
||||
value={formData.password}
|
||||
onChange={(e) => handleFormChange("password", e.target.value)}
|
||||
autoComplete="off"
|
||||
autoComplete="on"
|
||||
/>
|
||||
{showPassword ? (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-3.5 right-3 flex items-center justify-center text-placeholder"
|
||||
className="absolute right-3 top-3.5 flex items-center justify-center text-placeholder"
|
||||
onClick={() => setShowPassword(false)}
|
||||
>
|
||||
<EyeOff className="h-4 w-4" />
|
||||
@@ -178,7 +178,7 @@ export function InstanceSignInForm() {
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-3.5 right-3 flex items-center justify-center text-placeholder"
|
||||
className="absolute right-3 top-3.5 flex items-center justify-center text-placeholder"
|
||||
onClick={() => setShowPassword(true)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
|
||||
@@ -4,10 +4,31 @@
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import * as Sentry from "@sentry/react-router";
|
||||
import { startTransition, StrictMode } from "react";
|
||||
import { hydrateRoot } from "react-dom/client";
|
||||
import { HydratedRouter } from "react-router/dom";
|
||||
|
||||
Sentry.init({
|
||||
dsn: process.env.VITE_SENTRY_DSN,
|
||||
environment: process.env.VITE_SENTRY_ENVIRONMENT,
|
||||
sendDefaultPii: process.env.VITE_SENTRY_SEND_DEFAULT_PII ? process.env.VITE_SENTRY_SEND_DEFAULT_PII === "1" : false,
|
||||
release: process.env.VITE_APP_VERSION,
|
||||
tracesSampleRate: process.env.VITE_SENTRY_TRACES_SAMPLE_RATE
|
||||
? parseFloat(process.env.VITE_SENTRY_TRACES_SAMPLE_RATE)
|
||||
: 0.1,
|
||||
profilesSampleRate: process.env.VITE_SENTRY_PROFILES_SAMPLE_RATE
|
||||
? parseFloat(process.env.VITE_SENTRY_PROFILES_SAMPLE_RATE)
|
||||
: 0.1,
|
||||
replaysSessionSampleRate: process.env.VITE_SENTRY_REPLAYS_SESSION_SAMPLE_RATE
|
||||
? parseFloat(process.env.VITE_SENTRY_REPLAYS_SESSION_SAMPLE_RATE)
|
||||
: 0.1,
|
||||
replaysOnErrorSampleRate: process.env.VITE_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE
|
||||
? parseFloat(process.env.VITE_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE)
|
||||
: 1.0,
|
||||
integrations: [],
|
||||
});
|
||||
|
||||
startTransition(() => {
|
||||
hydrateRoot(
|
||||
document,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Links, Meta, Outlet, Scripts } from "react-router";
|
||||
import type { LinksFunction } from "react-router";
|
||||
import * as Sentry from "@sentry/react-router";
|
||||
import appleTouchIcon from "@/app/assets/favicon/apple-touch-icon.png?url";
|
||||
import favicon16 from "@/app/assets/favicon/favicon-16x16.png?url";
|
||||
import favicon32 from "@/app/assets/favicon/favicon-32x32.png?url";
|
||||
@@ -74,7 +75,7 @@ export const meta: Route.MetaFunction = () => [
|
||||
|
||||
export default function Root() {
|
||||
return (
|
||||
<div className="min-h-screen bg-canvas">
|
||||
<div className="bg-canvas min-h-screen">
|
||||
<Outlet />
|
||||
</div>
|
||||
);
|
||||
@@ -89,6 +90,10 @@ export function HydrateFallback() {
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
if (error) {
|
||||
Sentry.captureException(error);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>Something went wrong.</p>
|
||||
|
||||
@@ -22,8 +22,8 @@ export function AuthenticationMethodCard(props: Props) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex w-full items-center gap-14 rounded-lg bg-layer-2", {
|
||||
"border border-subtle px-4 py-3": withBorder,
|
||||
className={cn("w-full flex items-center gap-14 rounded-lg bg-layer-2", {
|
||||
"px-4 py-3 border border-subtle": withBorder,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
@@ -36,7 +36,7 @@ export function AuthenticationMethodCard(props: Props) {
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div
|
||||
className={cn("leading-5 font-medium text-primary", {
|
||||
className={cn("font-medium leading-5 text-primary", {
|
||||
"text-13": withBorder,
|
||||
"text-18": !withBorder,
|
||||
})}
|
||||
@@ -44,7 +44,7 @@ export function AuthenticationMethodCard(props: Props) {
|
||||
{name}
|
||||
</div>
|
||||
<div
|
||||
className={cn("leading-5 font-regular text-tertiary", {
|
||||
className={cn("font-regular leading-5 text-tertiary", {
|
||||
"text-11": withBorder,
|
||||
"text-13": !withBorder,
|
||||
})}
|
||||
|
||||
@@ -16,12 +16,12 @@ export function Banner(props: TBanner) {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-full rounded-md border p-2 ${type === "error" ? "border-danger-strong bg-danger-subtle" : "border-success-strong bg-success-subtle"}`}
|
||||
className={`rounded-md p-2 w-full border ${type === "error" ? "bg-danger-subtle border-danger-strong" : "bg-success-subtle border-success-strong"}`}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="flex-shrink-0">
|
||||
{type === "error" ? (
|
||||
<span className="flex h-6 w-6 items-center justify-center rounded-full">
|
||||
<span className="flex items-center justify-center h-6 w-6 rounded-full">
|
||||
<AlertCircle className="h-5 w-5 text-danger-primary" aria-hidden="true" />
|
||||
</span>
|
||||
) : (
|
||||
|
||||
@@ -22,12 +22,12 @@ export function BreadcrumbLink(props: Props) {
|
||||
{href ? (
|
||||
<Link className="flex items-center gap-1 text-13 font-medium text-tertiary hover:text-primary" href={href}>
|
||||
{icon && <div className="flex h-5 w-5 items-center justify-center overflow-hidden !text-16">{icon}</div>}
|
||||
<div className="relative line-clamp-1 block max-w-[150px] truncate overflow-hidden">{label}</div>
|
||||
<div className="relative line-clamp-1 block max-w-[150px] overflow-hidden truncate">{label}</div>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="flex cursor-default items-center gap-1 text-13 font-medium text-primary">
|
||||
{icon && <div className="flex h-5 w-5 items-center justify-center overflow-hidden">{icon}</div>}
|
||||
<div className="relative line-clamp-1 block max-w-[150px] truncate overflow-hidden">{label}</div>
|
||||
<div className="relative line-clamp-1 block max-w-[150px] overflow-hidden truncate">{label}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -16,9 +16,9 @@ export function CodeBlock({ children, className, darkerShade }: TProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-md border border-subtle bg-surface-2 px-0.5 text-11 font-semibold text-tertiary",
|
||||
"px-0.5 text-11 text-tertiary bg-surface-2 font-semibold rounded-md border border-subtle",
|
||||
{
|
||||
"border-subtle bg-layer-1 text-secondary": darkerShade,
|
||||
"text-secondary bg-layer-1 border-subtle": darkerShade,
|
||||
},
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -46,10 +46,10 @@ export function ConfirmDiscardModal(props: Props) {
|
||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
|
||||
>
|
||||
<Dialog.Panel className="relative transform overflow-hidden rounded-lg bg-surface-1 text-left shadow-raised-200 transition-all sm:my-8 sm:w-[30rem]">
|
||||
<div className="px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||
<div className="px-4 pb-4 pt-5 sm:p-6 sm:pb-4">
|
||||
<div className="sm:flex sm:items-start">
|
||||
<div className="mt-3 text-center sm:mt-0 sm:text-left">
|
||||
<Dialog.Title as="h3" className="text-16 leading-6 font-medium text-tertiary">
|
||||
<Dialog.Title as="h3" className="text-16 font-medium leading-6 text-tertiary">
|
||||
You have unsaved changes
|
||||
</Dialog.Title>
|
||||
<div className="mt-2">
|
||||
@@ -60,7 +60,7 @@ export function ConfirmDiscardModal(props: Props) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2 p-4 sm:px-6">
|
||||
<div className="flex justify-end items-center p-4 sm:px-6 gap-2">
|
||||
<Button variant="secondary" size="lg" onClick={handleClose}>
|
||||
Keep editing
|
||||
</Button>
|
||||
|
||||
@@ -67,7 +67,7 @@ export function ControllerInput(props: Props) {
|
||||
(showPassword ? (
|
||||
<button
|
||||
tabIndex={-1}
|
||||
className="absolute top-2.5 right-3 flex items-center justify-center text-placeholder"
|
||||
className="absolute right-3 top-2.5 flex items-center justify-center text-placeholder"
|
||||
onClick={() => setShowPassword(false)}
|
||||
>
|
||||
<EyeOff className="h-4 w-4" />
|
||||
@@ -75,7 +75,7 @@ export function ControllerInput(props: Props) {
|
||||
) : (
|
||||
<button
|
||||
tabIndex={-1}
|
||||
className="absolute top-2.5 right-3 flex items-center justify-center text-placeholder"
|
||||
className="absolute right-3 top-2.5 flex items-center justify-center text-placeholder"
|
||||
onClick={() => setShowPassword(true)}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
|
||||
@@ -25,7 +25,7 @@ export function EmptyState({ title, description, image, primaryButton, secondary
|
||||
<div className={`flex h-full w-full items-center justify-center`}>
|
||||
<div className="flex w-full flex-col items-center text-center">
|
||||
{image && <img src={image} className="w-52 sm:w-60" alt={primaryButton?.text || "button image"} />}
|
||||
<h6 className="mt-6 mb-3 text-18 font-semibold sm:mt-8">{title}</h6>
|
||||
<h6 className="mb-3 mt-6 text-18 font-semibold sm:mt-8">{title}</h6>
|
||||
{description && <p className="mb-7 px-5 text-tertiary sm:mb-8">{description}</p>}
|
||||
<div className="flex items-center gap-4">
|
||||
{primaryButton && (
|
||||
|
||||
@@ -21,10 +21,10 @@ export const HamburgerToggle = observer(function HamburgerToggle() {
|
||||
const { isSidebarCollapsed, toggleSidebar } = useTheme();
|
||||
return (
|
||||
<button
|
||||
className="group flex size-7 cursor-pointer items-center justify-center rounded-sm bg-layer-1 transition-all hover:bg-layer-1-hover md:hidden"
|
||||
className="size-7 rounded-sm flex justify-center items-center bg-layer-1 transition-all hover:bg-layer-1-hover cursor-pointer group md:hidden"
|
||||
onClick={() => toggleSidebar(!isSidebarCollapsed)}
|
||||
>
|
||||
<Menu size={14} className="text-secondary transition-all group-hover:text-primary" />
|
||||
<Menu size={14} className="text-secondary group-hover:text-primary transition-all" />
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -24,7 +24,7 @@ export const NewUserPopup = observer(function NewUserPopup() {
|
||||
|
||||
if (!isNewUserPopup) return <></>;
|
||||
return (
|
||||
<div className="shadow-md absolute right-8 bottom-8 w-96 rounded-lg border border-subtle bg-surface-1 p-6">
|
||||
<div className="absolute bottom-8 right-8 p-6 w-96 border border-subtle shadow-md rounded-lg bg-surface-1">
|
||||
<div className="flex gap-4">
|
||||
<div className="grow">
|
||||
<div className="text-14 font-semibold">Create workspace</div>
|
||||
@@ -41,7 +41,7 @@ export const NewUserPopup = observer(function NewUserPopup() {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center justify-center">
|
||||
<div className="shrink-0 flex items-center justify-center">
|
||||
<img
|
||||
src={resolveGeneralTheme(resolvedTheme) === "dark" ? TakeoffIconDark : TakeoffIconLight}
|
||||
height={80}
|
||||
|
||||
@@ -24,25 +24,25 @@ export const PageWrapper = (props: TPageWrapperProps) => {
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("mx-auto h-full w-full space-y-6 py-4", {
|
||||
"max-w-[1000px] md:px-4 2xl:max-w-[1200px]": size === "md",
|
||||
className={cn("mx-auto w-full h-full space-y-6 py-4", {
|
||||
"md:px-4 max-w-[1000px] 2xl:max-w-[1200px]": size === "md",
|
||||
"px-4 lg:px-12": size === "lg",
|
||||
})}
|
||||
>
|
||||
{customHeader ? (
|
||||
<div className="mx-4 shrink-0 space-y-1 border-b border-subtle py-4">{customHeader}</div>
|
||||
<div className="border-b border-subtle mx-4 py-4 space-y-1 shrink-0">{customHeader}</div>
|
||||
) : (
|
||||
header && (
|
||||
<div className="mx-4 flex shrink-0 items-center justify-between gap-4 space-y-1 border-b border-subtle py-4">
|
||||
<div className="flex items-center justify-between gap-4 border-b border-subtle mx-4 py-4 space-y-1 shrink-0">
|
||||
<div className={header.actions ? "flex flex-col gap-1" : "space-y-1"}>
|
||||
<div className="text-h5-semibold text-primary">{header.title}</div>
|
||||
<div className="text-body-sm-regular text-secondary">{header.description}</div>
|
||||
<div className="text-primary text-h5-semibold">{header.title}</div>
|
||||
<div className="text-secondary text-body-sm-regular">{header.description}</div>
|
||||
</div>
|
||||
{header.actions && <div className="shrink-0">{header.actions}</div>}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
<div className="vertical-scrollbar scrollbar-sm flex-grow overflow-hidden overflow-y-scroll px-4 pb-4">
|
||||
<div className="flex-grow overflow-hidden overflow-y-scroll vertical-scrollbar scrollbar-sm px-4 pb-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -24,12 +24,12 @@ export const InstanceFailureView = observer(function InstanceFailureView() {
|
||||
return (
|
||||
<>
|
||||
<AuthHeader />
|
||||
<div className="mt-10 flex w-full flex-grow flex-col items-center justify-center py-6">
|
||||
<div className="relative flex w-full max-w-[22.5rem] flex-col gap-6">
|
||||
<div className="relative flex flex-col items-center justify-center space-y-4">
|
||||
<div className="flex flex-col justify-center items-center flex-grow w-full py-6 mt-10">
|
||||
<div className="relative flex flex-col gap-6 max-w-[22.5rem] w-full">
|
||||
<div className="relative flex flex-col justify-center items-center space-y-4">
|
||||
<img src={instanceImage} alt="Instance failure illustration" />
|
||||
<h3 className="text-center text-20 font-medium text-on-color">Unable to fetch instance details.</h3>
|
||||
<p className="text-center text-14 font-medium">
|
||||
<h3 className="font-medium text-20 text-on-color text-center">Unable to fetch instance details.</h3>
|
||||
<p className="font-medium text-14 text-center">
|
||||
We were unable to fetch the details of the instance. Fret not, it might just be a connectivity issue.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
export function FormHeader({ heading, subHeading }: { heading: string; subHeading: string }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-20 leading-7 font-semibold text-primary">{heading}</span>
|
||||
<span className="text-16 leading-7 font-semibold text-placeholder">{subHeading}</span>
|
||||
<span className="text-20 font-semibold text-primary leading-7">{heading}</span>
|
||||
<span className="text-16 font-semibold text-placeholder leading-7">{subHeading}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,12 +11,12 @@ import PlaneTakeOffImage from "@/app/assets/images/plane-takeoff.png?url";
|
||||
|
||||
export function InstanceNotReady() {
|
||||
return (
|
||||
<div className="relative container mx-auto flex h-full w-full items-center justify-center px-5">
|
||||
<div className="relative w-auto max-w-2xl space-y-8 py-10">
|
||||
<div className="relative flex flex-col items-center justify-center space-y-4">
|
||||
<h1 className="pb-3 text-24 font-bold">Welcome aboard Plane!</h1>
|
||||
<div className="h-full w-full relative container px-5 mx-auto flex justify-center items-center">
|
||||
<div className="w-auto max-w-2xl relative space-y-8 py-10">
|
||||
<div className="relative flex flex-col justify-center items-center space-y-4">
|
||||
<h1 className="text-24 font-bold pb-3">Welcome aboard Plane!</h1>
|
||||
<img src={PlaneTakeOffImage} alt="Plane Logo" />
|
||||
<p className="text-14 font-medium text-placeholder">Get started by setting up your instance and workspace</p>
|
||||
<p className="font-medium text-14 text-placeholder">Get started by setting up your instance and workspace</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
|
||||
@@ -13,7 +13,7 @@ import { API_BASE_URL, E_PASSWORD_STRENGTH } from "@plane/constants";
|
||||
import { Button } from "@plane/propel/button";
|
||||
import { AuthService } from "@plane/services";
|
||||
import { Checkbox, Input, PasswordStrengthIndicator, Spinner } from "@plane/ui";
|
||||
import { getPasswordStrength, validatePersonName, validateCompanyName } from "@plane/utils";
|
||||
import { getPasswordStrength } from "@plane/utils";
|
||||
// components
|
||||
import { AuthHeader } from "@/app/(all)/(home)/auth-header";
|
||||
import { Banner } from "../common/banner";
|
||||
@@ -139,8 +139,8 @@ export function InstanceSetupForm() {
|
||||
return (
|
||||
<>
|
||||
<AuthHeader />
|
||||
<div className="mt-10 flex w-full flex-grow flex-col items-center justify-center py-6">
|
||||
<div className="relative flex w-full max-w-[22.5rem] flex-col gap-6">
|
||||
<div className="flex flex-col justify-center items-center flex-grow w-full py-6 mt-10">
|
||||
<div className="relative flex flex-col gap-6 max-w-[22.5rem] w-full">
|
||||
<FormHeader
|
||||
heading="Setup your Plane Instance"
|
||||
subHeading="Post setup you will be able to manage this Plane instance."
|
||||
@@ -160,9 +160,9 @@ export function InstanceSetupForm() {
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value={csrfToken} />
|
||||
<input type="hidden" name="is_telemetry_enabled" value={formData.is_telemetry_enabled ? "True" : "False"} />
|
||||
|
||||
<div className="flex flex-col items-center gap-4 sm:flex-row">
|
||||
<div className="flex flex-col sm:flex-row items-center gap-4">
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-13 font-medium text-tertiary" htmlFor="first_name">
|
||||
<label className="text-13 text-tertiary font-medium" htmlFor="first_name">
|
||||
First name <span className="text-danger-primary">*</span>
|
||||
</label>
|
||||
<Input
|
||||
@@ -173,19 +173,13 @@ export function InstanceSetupForm() {
|
||||
inputSize="md"
|
||||
placeholder="Wilber"
|
||||
value={formData.first_name}
|
||||
onChange={(e) => {
|
||||
const validation = validatePersonName(e.target.value);
|
||||
if (validation === true || e.target.value === "") {
|
||||
handleFormChange("first_name", e.target.value);
|
||||
}
|
||||
}}
|
||||
autoComplete="off"
|
||||
onChange={(e) => handleFormChange("first_name", e.target.value)}
|
||||
autoComplete="on"
|
||||
autoFocus
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-13 font-medium text-tertiary" htmlFor="last_name">
|
||||
<label className="text-13 text-tertiary font-medium" htmlFor="last_name">
|
||||
Last name <span className="text-danger-primary">*</span>
|
||||
</label>
|
||||
<Input
|
||||
@@ -196,20 +190,14 @@ export function InstanceSetupForm() {
|
||||
inputSize="md"
|
||||
placeholder="Wright"
|
||||
value={formData.last_name}
|
||||
onChange={(e) => {
|
||||
const validation = validatePersonName(e.target.value);
|
||||
if (validation === true || e.target.value === "") {
|
||||
handleFormChange("last_name", e.target.value);
|
||||
}
|
||||
}}
|
||||
autoComplete="off"
|
||||
maxLength={50}
|
||||
onChange={(e) => handleFormChange("last_name", e.target.value)}
|
||||
autoComplete="on"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-13 font-medium text-tertiary" htmlFor="email">
|
||||
<label className="text-13 text-tertiary font-medium" htmlFor="email">
|
||||
Email <span className="text-danger-primary">*</span>
|
||||
</label>
|
||||
<Input
|
||||
@@ -222,7 +210,7 @@ export function InstanceSetupForm() {
|
||||
value={formData.email}
|
||||
onChange={(e) => handleFormChange("email", e.target.value)}
|
||||
hasError={errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL ? true : false}
|
||||
autoComplete="off"
|
||||
autoComplete="on"
|
||||
/>
|
||||
{errorData.type && errorData.type === EErrorCodes.INVALID_EMAIL && errorData.message && (
|
||||
<p className="px-1 text-11 text-danger-primary">{errorData.message}</p>
|
||||
@@ -230,7 +218,7 @@ export function InstanceSetupForm() {
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-13 font-medium text-tertiary" htmlFor="company_name">
|
||||
<label className="text-13 text-tertiary font-medium" htmlFor="company_name">
|
||||
Company name <span className="text-danger-primary">*</span>
|
||||
</label>
|
||||
<Input
|
||||
@@ -241,18 +229,12 @@ export function InstanceSetupForm() {
|
||||
inputSize="md"
|
||||
placeholder="Company name"
|
||||
value={formData.company_name}
|
||||
onChange={(e) => {
|
||||
const validation = validateCompanyName(e.target.value, false);
|
||||
if (validation === true || e.target.value === "") {
|
||||
handleFormChange("company_name", e.target.value);
|
||||
}
|
||||
}}
|
||||
maxLength={80}
|
||||
onChange={(e) => handleFormChange("company_name", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-13 font-medium text-tertiary" htmlFor="password">
|
||||
<label className="text-13 text-tertiary font-medium" htmlFor="password">
|
||||
Set a password <span className="text-danger-primary">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
@@ -268,13 +250,13 @@ export function InstanceSetupForm() {
|
||||
hasError={errorData.type && errorData.type === EErrorCodes.INVALID_PASSWORD ? true : false}
|
||||
onFocus={() => setIsPasswordInputFocused(true)}
|
||||
onBlur={() => setIsPasswordInputFocused(false)}
|
||||
autoComplete="new-password"
|
||||
autoComplete="on"
|
||||
/>
|
||||
{showPassword.password ? (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className="absolute top-3.5 right-3 flex items-center justify-center text-placeholder"
|
||||
className="absolute right-3 top-3.5 flex items-center justify-center text-placeholder"
|
||||
onClick={() => handleShowPassword("password")}
|
||||
>
|
||||
<EyeOff className="h-4 w-4" />
|
||||
@@ -283,7 +265,7 @@ export function InstanceSetupForm() {
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className="absolute top-3.5 right-3 flex items-center justify-center text-placeholder"
|
||||
className="absolute right-3 top-3.5 flex items-center justify-center text-placeholder"
|
||||
onClick={() => handleShowPassword("password")}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
@@ -297,7 +279,7 @@ export function InstanceSetupForm() {
|
||||
</div>
|
||||
|
||||
<div className="w-full space-y-1">
|
||||
<label className="text-13 font-medium text-tertiary" htmlFor="confirm_password">
|
||||
<label className="text-13 text-tertiary font-medium" htmlFor="confirm_password">
|
||||
Confirm password <span className="text-danger-primary">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
@@ -312,13 +294,12 @@ export function InstanceSetupForm() {
|
||||
className="w-full border border-subtle !bg-surface-1 pr-12 placeholder:text-placeholder"
|
||||
onFocus={() => setIsRetryPasswordInputFocused(true)}
|
||||
onBlur={() => setIsRetryPasswordInputFocused(false)}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
{showPassword.retypePassword ? (
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className="absolute top-3.5 right-3 flex items-center justify-center text-placeholder"
|
||||
className="absolute right-3 top-3.5 flex items-center justify-center text-placeholder"
|
||||
onClick={() => handleShowPassword("retypePassword")}
|
||||
>
|
||||
<EyeOff className="h-4 w-4" />
|
||||
@@ -327,7 +308,7 @@ export function InstanceSetupForm() {
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
className="absolute top-3.5 right-3 flex items-center justify-center text-placeholder"
|
||||
className="absolute right-3 top-3.5 flex items-center justify-center text-placeholder"
|
||||
onClick={() => handleShowPassword("retypePassword")}
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
@@ -344,21 +325,21 @@ export function InstanceSetupForm() {
|
||||
<div className="relative flex gap-2">
|
||||
<div>
|
||||
<Checkbox
|
||||
className="h-4 w-4"
|
||||
className="w-4 h-4"
|
||||
iconClassName="w-3 h-3"
|
||||
id="is_telemetry_enabled"
|
||||
onChange={() => handleFormChange("is_telemetry_enabled", !formData.is_telemetry_enabled)}
|
||||
checked={formData.is_telemetry_enabled}
|
||||
/>
|
||||
</div>
|
||||
<label className="cursor-pointer text-13 font-medium text-tertiary" htmlFor="is_telemetry_enabled">
|
||||
<label className="text-13 text-tertiary font-medium cursor-pointer" htmlFor="is_telemetry_enabled">
|
||||
Allow Plane to anonymously collect usage events.{" "}
|
||||
<a
|
||||
tabIndex={-1}
|
||||
href="https://developers.plane.so/self-hosting/telemetry"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-500 hover:text-blue-600 flex-shrink-0 text-13 font-medium"
|
||||
className="text-13 font-medium text-blue-500 hover:text-blue-600 flex-shrink-0"
|
||||
>
|
||||
See More
|
||||
</a>
|
||||
|
||||
@@ -30,19 +30,19 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({ workspace
|
||||
key={workspaceId}
|
||||
href={`${WEB_BASE_URL}/${encodeURIComponent(workspace.slug)}`}
|
||||
target="_blank"
|
||||
className="group flex items-center justify-between gap-2.5 truncate rounded-lg border border-subtle bg-layer-1 p-3 hover:border-subtle-1 hover:bg-layer-1-hover hover:shadow-raised-100"
|
||||
className="group flex items-center justify-between p-3 gap-2.5 truncate border border-subtle hover:border-subtle-1 bg-layer-1 hover:bg-layer-1-hover hover:shadow-raised-100 rounded-lg"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<span
|
||||
className={`relative mt-1 flex h-8 w-8 flex-shrink-0 items-center justify-center p-2 text-11 uppercase ${
|
||||
className={`relative flex h-8 w-8 flex-shrink-0 items-center justify-center p-2 mt-1 text-11 uppercase ${
|
||||
!workspace?.logo_url && "rounded-lg bg-accent-primary text-on-color"
|
||||
}`}
|
||||
>
|
||||
{workspace?.logo_url && workspace.logo_url !== "" ? (
|
||||
<img
|
||||
src={getFileURL(workspace.logo_url)}
|
||||
className="absolute top-0 left-0 h-full w-full rounded-sm object-cover"
|
||||
className="absolute left-0 top-0 h-full w-full rounded-sm object-cover"
|
||||
alt="Workspace Logo"
|
||||
/>
|
||||
) : (
|
||||
@@ -50,7 +50,7 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({ workspace
|
||||
)}
|
||||
</span>
|
||||
<div className="flex flex-col items-start gap-1">
|
||||
<div className="flex w-full flex-wrap items-center gap-2.5">
|
||||
<div className="flex flex-wrap w-full items-center gap-2.5">
|
||||
<h3 className={`text-14 font-medium capitalize`}>{workspace.name}</h3>/
|
||||
<Tooltip tooltipContent="The unique URL of your workspace">
|
||||
<h4 className="text-13 text-tertiary">[{workspace.slug}]</h4>
|
||||
@@ -58,14 +58,14 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({ workspace
|
||||
</div>
|
||||
{workspace.owner.email && (
|
||||
<div className="flex items-center gap-1 text-11">
|
||||
<h3 className="font-medium text-secondary">Owned by:</h3>
|
||||
<h3 className="text-secondary font-medium">Owned by:</h3>
|
||||
<h4 className="text-tertiary">{workspace.owner.email}</h4>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2.5 text-11">
|
||||
{workspace.total_projects !== null && (
|
||||
<span className="flex items-center gap-1">
|
||||
<h3 className="font-medium text-secondary">Total projects:</h3>
|
||||
<h3 className="text-secondary font-medium">Total projects:</h3>
|
||||
<h4 className="text-tertiary">{workspace.total_projects}</h4>
|
||||
</span>
|
||||
)}
|
||||
@@ -73,7 +73,7 @@ export const WorkspaceListItem = observer(function WorkspaceListItem({ workspace
|
||||
<>
|
||||
•
|
||||
<span className="flex items-center gap-1">
|
||||
<h3 className="font-medium text-secondary">Total members:</h3>
|
||||
<h3 className="text-secondary font-medium">Total members:</h3>
|
||||
<h4 className="text-tertiary">{workspace.total_members}</h4>
|
||||
</span>
|
||||
</>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "admin",
|
||||
"version": "1.2.0",
|
||||
"private": true,
|
||||
"description": "Admin UI for Plane",
|
||||
"version": "1.2.0",
|
||||
"license": "AGPL-3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "react-router dev --port 3001",
|
||||
@@ -11,11 +11,11 @@
|
||||
"preview": "react-router build && serve -s build/client -l 3001",
|
||||
"start": "serve -s build/client -l 3001",
|
||||
"clean": "rm -rf .turbo && rm -rf .next && rm -rf node_modules && rm -rf dist && rm -rf build",
|
||||
"check:lint": "oxlint --max-warnings=759 .",
|
||||
"check:lint": "eslint . --cache --cache-location node_modules/.cache/eslint/ --max-warnings=485",
|
||||
"check:types": "react-router typegen && tsc --noEmit",
|
||||
"check:format": "oxfmt --check .",
|
||||
"fix:lint": "oxlint --fix .",
|
||||
"fix:format": "oxfmt ."
|
||||
"check:format": "prettier . --cache --check",
|
||||
"fix:lint": "eslint . --cache --cache-location node_modules/.cache/eslint/ --fix --max-warnings=485",
|
||||
"fix:format": "prettier . --cache --write"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bprogress/core": "catalog:",
|
||||
@@ -31,6 +31,7 @@
|
||||
"@plane/ui": "workspace:*",
|
||||
"@plane/utils": "workspace:*",
|
||||
"@react-router/node": "catalog:",
|
||||
"@sentry/react-router": "catalog:",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@tanstack/virtual-core": "^3.13.12",
|
||||
"axios": "catalog:",
|
||||
|
||||
@@ -6,10 +6,6 @@
|
||||
import random
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
# Python imports
|
||||
import re
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Project, ProjectIdentifier, WorkspaceMember, State, Estimate
|
||||
|
||||
@@ -105,15 +101,6 @@ class ProjectCreateSerializer(BaseSerializer):
|
||||
]
|
||||
|
||||
def validate(self, data):
|
||||
project_name = data.get("name", None)
|
||||
project_identifier = data.get("identifier", None)
|
||||
|
||||
if project_name is not None and re.match(Project.FORBIDDEN_IDENTIFIER_CHARS_PATTERN, project_name):
|
||||
raise serializers.ValidationError("Project name cannot contain special characters.")
|
||||
|
||||
if project_identifier is not None and re.match(Project.FORBIDDEN_IDENTIFIER_CHARS_PATTERN, project_identifier):
|
||||
raise serializers.ValidationError("Project identifier cannot contain special characters.")
|
||||
|
||||
if data.get("project_lead", None) is not None:
|
||||
# Check if the project lead is a member of the workspace
|
||||
if not WorkspaceMember.objects.filter(
|
||||
@@ -173,15 +160,6 @@ class ProjectUpdateSerializer(ProjectCreateSerializer):
|
||||
read_only_fields = ProjectCreateSerializer.Meta.read_only_fields
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
project_name = validated_data.get("name", None)
|
||||
project_identifier = validated_data.get("identifier", None)
|
||||
|
||||
if project_name is not None and re.match(Project.FORBIDDEN_IDENTIFIER_CHARS_PATTERN, project_name):
|
||||
raise serializers.ValidationError("Project name cannot contain special characters.")
|
||||
|
||||
if project_identifier is not None and re.match(Project.FORBIDDEN_IDENTIFIER_CHARS_PATTERN, project_identifier):
|
||||
raise serializers.ValidationError("Project identifier cannot contain special characters.")
|
||||
|
||||
"""Update a project"""
|
||||
if (
|
||||
validated_data.get("default_state", None) is not None
|
||||
@@ -232,15 +210,6 @@ class ProjectSerializer(BaseSerializer):
|
||||
]
|
||||
|
||||
def validate(self, data):
|
||||
project_name = data.get("name", None)
|
||||
project_identifier = data.get("identifier", None)
|
||||
|
||||
if project_name is not None and re.match(Project.FORBIDDEN_IDENTIFIER_CHARS_PATTERN, project_name):
|
||||
raise serializers.ValidationError("Project name cannot contain special characters.")
|
||||
|
||||
if project_identifier is not None and re.match(Project.FORBIDDEN_IDENTIFIER_CHARS_PATTERN, project_identifier):
|
||||
raise serializers.ValidationError("Project identifier cannot contain special characters.")
|
||||
|
||||
# Check project lead should be a member of the workspace
|
||||
if (
|
||||
data.get("project_lead", None) is not None
|
||||
|
||||
@@ -8,7 +8,6 @@ from plane.api.views import (
|
||||
ProjectListCreateAPIEndpoint,
|
||||
ProjectDetailAPIEndpoint,
|
||||
ProjectArchiveUnarchiveAPIEndpoint,
|
||||
ProjectSummaryAPIEndpoint,
|
||||
)
|
||||
|
||||
urlpatterns = [
|
||||
@@ -27,9 +26,4 @@ urlpatterns = [
|
||||
ProjectArchiveUnarchiveAPIEndpoint.as_view(http_method_names=["post", "delete"]),
|
||||
name="project-archive-unarchive",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/projects/<uuid:project_id>/summary/",
|
||||
ProjectSummaryAPIEndpoint.as_view(http_method_names=["get"]),
|
||||
name="project-summary",
|
||||
),
|
||||
]
|
||||
|
||||
@@ -6,7 +6,6 @@ from .project import (
|
||||
ProjectListCreateAPIEndpoint,
|
||||
ProjectDetailAPIEndpoint,
|
||||
ProjectArchiveUnarchiveAPIEndpoint,
|
||||
ProjectSummaryAPIEndpoint,
|
||||
)
|
||||
|
||||
from .state import (
|
||||
|
||||
@@ -414,7 +414,7 @@ class ModuleDetailAPIEndpoint(BaseAPIView):
|
||||
{"error": "Archived module cannot be edited"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
serializer = ModuleUpdateSerializer(module, data=request.data, context={"project_id": project_id}, partial=True)
|
||||
serializer = ModuleSerializer(module, data=request.data, context={"project_id": project_id}, partial=True)
|
||||
if serializer.is_valid():
|
||||
if (
|
||||
request.data.get("external_id")
|
||||
|
||||
@@ -7,8 +7,7 @@ import json
|
||||
|
||||
# Django imports
|
||||
from django.db import IntegrityError
|
||||
from django.db.models import Exists, F, Func, OuterRef, Prefetch, Q, Subquery, Count
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.db.models import Exists, F, Func, OuterRef, Prefetch, Q, Subquery
|
||||
from django.utils import timezone
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
|
||||
@@ -23,6 +22,7 @@ from drf_spectacular.utils import OpenApiResponse, OpenApiRequest
|
||||
from plane.db.models import (
|
||||
Cycle,
|
||||
Intake,
|
||||
ProjectUserProperty,
|
||||
Module,
|
||||
Project,
|
||||
DeployBoard,
|
||||
@@ -31,11 +31,6 @@ from plane.db.models import (
|
||||
DEFAULT_STATES,
|
||||
Workspace,
|
||||
UserFavorite,
|
||||
Label,
|
||||
Issue,
|
||||
StateGroup,
|
||||
IntakeIssue,
|
||||
ProjectPage,
|
||||
)
|
||||
from plane.bgtasks.webhook_task import model_activity, webhook_activity
|
||||
from .base import BaseAPIView
|
||||
@@ -45,7 +40,7 @@ from plane.api.serializers import (
|
||||
ProjectCreateSerializer,
|
||||
ProjectUpdateSerializer,
|
||||
)
|
||||
from plane.app.permissions import ProjectBasePermission, WorkSpaceAdminPermission
|
||||
from plane.app.permissions import ProjectBasePermission
|
||||
from plane.utils.openapi import (
|
||||
project_docs,
|
||||
PROJECT_ID_PARAMETER,
|
||||
@@ -188,9 +183,9 @@ class ProjectListCreateAPIEndpoint(BaseAPIView):
|
||||
return self.paginate(
|
||||
request=request,
|
||||
queryset=(projects),
|
||||
on_results=lambda projects: (
|
||||
ProjectSerializer(projects, many=True, fields=self.fields, expand=self.expand).data
|
||||
),
|
||||
on_results=lambda projects: ProjectSerializer(
|
||||
projects, many=True, fields=self.fields, expand=self.expand
|
||||
).data,
|
||||
)
|
||||
|
||||
@project_docs(
|
||||
@@ -554,119 +549,3 @@ class ProjectArchiveUnarchiveAPIEndpoint(BaseAPIView):
|
||||
project.archived_at = None
|
||||
project.save()
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
ALLOWED_PROJECT_SUMMARY_FIELDS = [
|
||||
"members",
|
||||
"states",
|
||||
"labels",
|
||||
"cycles",
|
||||
"modules",
|
||||
"issues",
|
||||
"intakes",
|
||||
"pages",
|
||||
]
|
||||
|
||||
|
||||
class ProjectSummaryAPIEndpoint(BaseAPIView):
|
||||
permission_classes = [WorkSpaceAdminPermission]
|
||||
use_read_replica = True
|
||||
|
||||
def get(self, request, slug, project_id):
|
||||
"""Get project summary
|
||||
|
||||
Get the summary of a project
|
||||
"""
|
||||
project = Project.objects.filter(pk=project_id, workspace__slug=slug).first()
|
||||
if not project:
|
||||
return Response({"error": "Project not found"}, status=status.HTTP_404_NOT_FOUND)
|
||||
fields = request.GET.get("fields", "").split(",")
|
||||
requested_fields = set(filter(None, (f.strip() for f in fields))) & set(ALLOWED_PROJECT_SUMMARY_FIELDS)
|
||||
if not requested_fields:
|
||||
requested_fields = set(ALLOWED_PROJECT_SUMMARY_FIELDS)
|
||||
|
||||
# Single DB round-trip with only requested count subqueries
|
||||
counts = self._get_all_summary_counts(project_id, requested_fields)
|
||||
counts_dict = {field: counts[field] for field in requested_fields}
|
||||
summary = {
|
||||
"id": project.id,
|
||||
"name": project.name,
|
||||
"identifier": project.identifier,
|
||||
"counts": counts_dict,
|
||||
}
|
||||
return Response(summary, status=status.HTTP_200_OK)
|
||||
|
||||
# Getting all summary counts in one ORM query; only runs subqueries for requested fields.
|
||||
def _get_all_summary_counts(self, project_id, requested_fields):
|
||||
"""Return requested summary counts in one ORM query; only runs subqueries for requested fields."""
|
||||
|
||||
# Using a different annotation name for 'pages' to avoid conflict with Project.pages (M2M from Page)
|
||||
def _annotation_name(field):
|
||||
return "pages_count" if field == "pages" else field
|
||||
|
||||
subquery_builders = {
|
||||
"members": lambda: (
|
||||
ProjectMember.objects.filter(project_id=OuterRef("pk"), is_active=True)
|
||||
.values("project_id")
|
||||
.annotate(count=Count("*"))
|
||||
.values("count")
|
||||
),
|
||||
"states": lambda: (
|
||||
State.objects.filter(project_id=OuterRef("pk"))
|
||||
.values("project_id")
|
||||
.annotate(count=Count("*"))
|
||||
.values("count")
|
||||
),
|
||||
"labels": lambda: (
|
||||
Label.objects.filter(project_id=OuterRef("pk"))
|
||||
.values("project_id")
|
||||
.annotate(count=Count("*"))
|
||||
.values("count")
|
||||
),
|
||||
"cycles": lambda: (
|
||||
Cycle.objects.filter(project_id=OuterRef("pk"))
|
||||
.values("project_id")
|
||||
.annotate(count=Count("*"))
|
||||
.values("count")
|
||||
),
|
||||
"modules": lambda: (
|
||||
Module.objects.filter(project_id=OuterRef("pk"))
|
||||
.values("project_id")
|
||||
.annotate(count=Count("*"))
|
||||
.values("count")
|
||||
),
|
||||
"issues": lambda: (
|
||||
Issue.objects.filter(project_id=OuterRef("pk"))
|
||||
.exclude(state__group=StateGroup.TRIAGE.value)
|
||||
.values("project_id")
|
||||
.annotate(count=Count("*"))
|
||||
.values("count")
|
||||
),
|
||||
"intakes": lambda: (
|
||||
IntakeIssue.objects.filter(project_id=OuterRef("pk"))
|
||||
.values("project_id")
|
||||
.annotate(count=Count("*"))
|
||||
.values("count")
|
||||
),
|
||||
"pages": lambda: (
|
||||
ProjectPage.objects.filter(project_id=OuterRef("pk"))
|
||||
.values("project_id")
|
||||
.annotate(count=Count("*"))
|
||||
.values("count")
|
||||
),
|
||||
}
|
||||
|
||||
# Build annotations dictionary for the requested fields
|
||||
annotations = {
|
||||
_annotation_name(field): Coalesce(Subquery(subquery_builders[field]()), 0) for field in requested_fields
|
||||
}
|
||||
|
||||
# Prepare values list for the annotation names
|
||||
fields_list = sorted(requested_fields)
|
||||
values_list = [_annotation_name(f) for f in fields_list]
|
||||
# Execute the query and get the result
|
||||
query_result = Project.objects.filter(pk=project_id).annotate(**annotations).values(*values_list).first()
|
||||
if not query_result:
|
||||
return {field: 0 for field in requested_fields}
|
||||
# Return the result as a dictionary
|
||||
return {field: query_result[_annotation_name(field)] for field in requested_fields}
|
||||
|
||||
@@ -5,9 +5,6 @@
|
||||
# Third party imports
|
||||
from rest_framework import serializers
|
||||
|
||||
# Python imports
|
||||
import re
|
||||
|
||||
# Module imports
|
||||
from .base import BaseSerializer, DynamicBaseSerializer
|
||||
from django.db.models import Max
|
||||
@@ -40,9 +37,6 @@ class ProjectSerializer(BaseSerializer):
|
||||
project_id = self.instance.id if self.instance else None
|
||||
workspace_id = self.context["workspace_id"]
|
||||
|
||||
if re.match(Project.FORBIDDEN_IDENTIFIER_CHARS_PATTERN, name):
|
||||
raise serializers.ValidationError(detail="PROJECT_NAME_CANNOT_CONTAIN_SPECIAL_CHARACTERS")
|
||||
|
||||
project = Project.objects.filter(name=name, workspace_id=workspace_id)
|
||||
|
||||
if project_id:
|
||||
@@ -59,9 +53,6 @@ class ProjectSerializer(BaseSerializer):
|
||||
project_id = self.instance.id if self.instance else None
|
||||
workspace_id = self.context["workspace_id"]
|
||||
|
||||
if re.match(Project.FORBIDDEN_IDENTIFIER_CHARS_PATTERN, identifier):
|
||||
raise serializers.ValidationError(detail="PROJECT_IDENTIFIER_CANNOT_CONTAIN_SPECIAL_CHARACTERS")
|
||||
|
||||
project = Project.objects.filter(identifier=identifier, workspace_id=workspace_id)
|
||||
|
||||
if project_id:
|
||||
|
||||
@@ -38,7 +38,7 @@ class WebhookSerializer(DynamicBaseSerializer):
|
||||
|
||||
for addr in ip_addresses:
|
||||
ip = ipaddress.ip_address(addr[4][0])
|
||||
if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local:
|
||||
if ip.is_loopback:
|
||||
raise serializers.ValidationError({"url": "URL resolves to a blocked IP address."})
|
||||
|
||||
# Additional validation for multiple request domains and their subdomains
|
||||
@@ -73,7 +73,7 @@ class WebhookSerializer(DynamicBaseSerializer):
|
||||
|
||||
for addr in ip_addresses:
|
||||
ip = ipaddress.ip_address(addr[4][0])
|
||||
if ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local:
|
||||
if ip.is_loopback:
|
||||
raise serializers.ValidationError({"url": "URL resolves to a blocked IP address."})
|
||||
|
||||
# Additional validation for multiple request domains and their subdomains
|
||||
|
||||
@@ -111,7 +111,7 @@ class WorkSpaceMemberInviteSerializer(BaseSerializer):
|
||||
invite_link = serializers.SerializerMethodField()
|
||||
|
||||
def get_invite_link(self, obj):
|
||||
return f"/workspace-invitations/?invitation_id={obj.id}&slug={obj.workspace.slug}&token={obj.token}"
|
||||
return f"/workspace-invitations/?invitation_id={obj.id}&email={obj.email}&slug={obj.workspace.slug}"
|
||||
|
||||
class Meta:
|
||||
model = WorkspaceMemberInvite
|
||||
|
||||
@@ -579,7 +579,7 @@ class ProjectAssetEndpoint(BaseAPIView):
|
||||
@allow_permission([ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST])
|
||||
def patch(self, request, slug, project_id, pk):
|
||||
# get the asset id
|
||||
asset = FileAsset.objects.get(id=pk, workspace__slug=slug, project_id=project_id)
|
||||
asset = FileAsset.objects.get(id=pk)
|
||||
# get the storage metadata
|
||||
asset.is_uploaded = True
|
||||
# get the storage metadata
|
||||
|
||||
@@ -60,11 +60,7 @@ class IssueAttachmentEndpoint(BaseAPIView):
|
||||
|
||||
@allow_permission([ROLE.ADMIN], creator=True, model=FileAsset)
|
||||
def delete(self, request, slug, project_id, issue_id, pk):
|
||||
issue_attachment = FileAsset.objects.filter(
|
||||
pk=pk, workspace__slug=slug, project_id=project_id, issue_id=issue_id
|
||||
).first()
|
||||
if not issue_attachment:
|
||||
return Response(status=status.HTTP_404_NOT_FOUND)
|
||||
issue_attachment = FileAsset.objects.get(pk=pk)
|
||||
issue_attachment.asset.delete(save=False)
|
||||
issue_attachment.delete()
|
||||
issue_activity.delay(
|
||||
|
||||
@@ -50,7 +50,7 @@ from plane.utils.error_codes import ERROR_CODES
|
||||
# Local imports
|
||||
from ..base import BaseAPIView, BaseViewSet
|
||||
from plane.bgtasks.page_transaction_task import page_transaction
|
||||
from plane.bgtasks.page_version_task import track_page_version
|
||||
from plane.bgtasks.page_version_task import page_version
|
||||
from plane.bgtasks.recent_visited_task import recent_visited_task
|
||||
from plane.bgtasks.copy_s3_object import copy_s3_objects_of_description_and_assets
|
||||
from plane.app.permissions import ProjectPagePermission
|
||||
@@ -545,28 +545,26 @@ class PagesDescriptionViewSet(BaseViewSet):
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
# Store the old description_html before saving (needed for both tasks)
|
||||
old_description_html = page.description_html
|
||||
|
||||
# Serialize the existing instance
|
||||
existing_instance = json.dumps({"description_html": old_description_html}, cls=DjangoJSONEncoder)
|
||||
existing_instance = json.dumps({"description_html": page.description_html}, cls=DjangoJSONEncoder)
|
||||
|
||||
# Use serializer for validation and update
|
||||
serializer = PageBinaryUpdateSerializer(page, data=request.data, partial=True)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
|
||||
# Capture the page transaction
|
||||
if request.data.get("description_html"):
|
||||
page_transaction.delay(
|
||||
new_description_html=request.data.get("description_html", "<p></p>"),
|
||||
old_description_html=old_description_html,
|
||||
old_description_html=page.description_html,
|
||||
page_id=page_id,
|
||||
)
|
||||
|
||||
# Update the page using serializer
|
||||
updated_page = serializer.save()
|
||||
|
||||
# Run background tasks
|
||||
track_page_version.delay(
|
||||
page_id=page_id,
|
||||
page_version.delay(
|
||||
page_id=updated_page.id,
|
||||
existing_instance=existing_instance,
|
||||
user_id=request.user.id,
|
||||
)
|
||||
|
||||
@@ -8,7 +8,7 @@ import json
|
||||
|
||||
# Django imports
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery, Count
|
||||
from django.db.models import Exists, F, OuterRef, Prefetch, Q, Subquery
|
||||
from django.utils import timezone
|
||||
|
||||
# Third Party imports
|
||||
@@ -28,18 +28,18 @@ from plane.bgtasks.webhook_task import model_activity, webhook_activity
|
||||
from plane.db.models import (
|
||||
UserFavorite,
|
||||
DeployBoard,
|
||||
ProjectUserProperty,
|
||||
Intake,
|
||||
Project,
|
||||
ProjectIdentifier,
|
||||
ProjectMember,
|
||||
ProjectNetwork,
|
||||
ProjectUserProperty,
|
||||
State,
|
||||
DEFAULT_STATES,
|
||||
UserFavorite,
|
||||
Workspace,
|
||||
WorkspaceMember,
|
||||
)
|
||||
from plane.db.models.intake import IntakeIssueStatus
|
||||
from plane.utils.host import base_host
|
||||
|
||||
|
||||
@@ -155,15 +155,6 @@ class ProjectViewSet(BaseViewSet):
|
||||
is_active=True,
|
||||
).values("role")
|
||||
)
|
||||
.annotate(
|
||||
intake_count=Count(
|
||||
"project_intakeissue",
|
||||
filter=Q(
|
||||
project_intakeissue__status=IntakeIssueStatus.PENDING.value,
|
||||
project_intakeissue__deleted_at__isnull=True,
|
||||
),
|
||||
)
|
||||
)
|
||||
.annotate(inbox_view=F("intake_view"))
|
||||
.annotate(sort_order=Subquery(sort_order))
|
||||
.distinct()
|
||||
@@ -174,7 +165,6 @@ class ProjectViewSet(BaseViewSet):
|
||||
"sort_order",
|
||||
"logo_props",
|
||||
"member_role",
|
||||
"intake_count",
|
||||
"archived_at",
|
||||
"workspace",
|
||||
"cycle_view",
|
||||
|
||||
@@ -163,10 +163,10 @@ class WorkspaceJoinEndpoint(BaseAPIView):
|
||||
def post(self, request, slug, pk):
|
||||
workspace_invite = WorkspaceMemberInvite.objects.get(pk=pk, workspace__slug=slug)
|
||||
|
||||
token = request.data.get("token", "")
|
||||
email = request.data.get("email", "")
|
||||
|
||||
# Validate the token to verify the user received the invitation email
|
||||
if not token or workspace_invite.token != token:
|
||||
# Check the email
|
||||
if email == "" or workspace_invite.email != email:
|
||||
return Response(
|
||||
{"error": "You do not have permission to join the workspace"},
|
||||
status=status.HTTP_403_FORBIDDEN,
|
||||
@@ -180,7 +180,7 @@ class WorkspaceJoinEndpoint(BaseAPIView):
|
||||
|
||||
if workspace_invite.accepted:
|
||||
# Check if the user created account after invitation
|
||||
user = User.objects.filter(email=workspace_invite.email).first()
|
||||
user = User.objects.filter(email=email).first()
|
||||
|
||||
# If the user is present then create the workspace member
|
||||
if user is not None:
|
||||
|
||||
@@ -3,33 +3,29 @@
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Python imports
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from io import BytesIO
|
||||
|
||||
import requests
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import validate_email
|
||||
from io import BytesIO
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.core.validators import validate_email
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.conf import settings
|
||||
|
||||
# Third party imports
|
||||
from zxcvbn import zxcvbn
|
||||
|
||||
from plane.bgtasks.user_activation_email_task import user_activation_email
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import FileAsset, Profile, User, WorkspaceMemberInvite
|
||||
from plane.db.models import Profile, User, WorkspaceMemberInvite, FileAsset
|
||||
from plane.license.utils.instance_value import get_configuration_value
|
||||
from plane.settings.storage import S3Storage
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from .error import AuthenticationException, AUTHENTICATION_ERROR_CODES
|
||||
from plane.bgtasks.user_activation_email_task import user_activation_email
|
||||
from plane.utils.host import base_host
|
||||
from plane.utils.ip_address import get_client_ip
|
||||
|
||||
from .error import AUTHENTICATION_ERROR_CODES, AuthenticationException
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.settings.storage import S3Storage
|
||||
|
||||
|
||||
class Adapter:
|
||||
@@ -41,7 +37,6 @@ class Adapter:
|
||||
self.callback = callback
|
||||
self.token_data = None
|
||||
self.user_data = None
|
||||
self.logger = logging.getLogger("plane.authentication")
|
||||
|
||||
def get_user_token(self, data, headers=None):
|
||||
raise NotImplementedError
|
||||
@@ -64,7 +59,6 @@ class Adapter:
|
||||
def sanitize_email(self, email):
|
||||
# Check if email is present
|
||||
if not email:
|
||||
self.logger.error("Email is not present")
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"],
|
||||
error_message="INVALID_EMAIL",
|
||||
@@ -78,7 +72,6 @@ class Adapter:
|
||||
try:
|
||||
validate_email(email)
|
||||
except ValidationError:
|
||||
self.logger.warning(f"Email is not valid: {email}")
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["INVALID_EMAIL"],
|
||||
error_message="INVALID_EMAIL",
|
||||
@@ -91,10 +84,9 @@ class Adapter:
|
||||
"""Validate password strength"""
|
||||
results = zxcvbn(self.code)
|
||||
if results["score"] < 3:
|
||||
self.logger.warning("Password is not strong enough")
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_TOO_WEAK"],
|
||||
error_message="PASSWORD_TOO_WEAK",
|
||||
error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"],
|
||||
error_message="INVALID_PASSWORD",
|
||||
payload={"email": email},
|
||||
)
|
||||
return
|
||||
@@ -109,7 +101,6 @@ class Adapter:
|
||||
|
||||
# Check if sign up is disabled and invite is present or not
|
||||
if ENABLE_SIGNUP == "0" and not WorkspaceMemberInvite.objects.filter(email=email).exists():
|
||||
self.logger.warning("Sign up is disabled and invite is not present")
|
||||
# Raise exception
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["SIGNUP_DISABLED"],
|
||||
|
||||
@@ -13,7 +13,6 @@ AUTHENTICATION_ERROR_CODES = {
|
||||
"USER_ACCOUNT_DEACTIVATED": 5019,
|
||||
# Password strength
|
||||
"INVALID_PASSWORD": 5020,
|
||||
"PASSWORD_TOO_WEAK": 5021,
|
||||
"SMTP_NOT_CONFIGURED": 5025,
|
||||
# Sign Up
|
||||
"USER_ALREADY_EXIST": 5030,
|
||||
|
||||
@@ -4,21 +4,20 @@
|
||||
|
||||
# Python imports
|
||||
import requests
|
||||
from django.db import DatabaseError, IntegrityError
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
|
||||
from plane.authentication.adapter.error import (
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
AuthenticationException,
|
||||
)
|
||||
from django.db import DatabaseError, IntegrityError
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Account
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
from .base import Adapter
|
||||
from plane.authentication.adapter.error import (
|
||||
AuthenticationException,
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
)
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
class OauthAdapter(Adapter):
|
||||
@@ -79,7 +78,6 @@ class OauthAdapter(Adapter):
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.RequestException:
|
||||
self.logger.warning("Error getting user token")
|
||||
code = self.authentication_error_code()
|
||||
raise AuthenticationException(error_code=AUTHENTICATION_ERROR_CODES[code], error_message=str(code))
|
||||
|
||||
@@ -90,12 +88,6 @@ class OauthAdapter(Adapter):
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except requests.RequestException:
|
||||
self.logger.warning(
|
||||
"Error getting user response",
|
||||
extra={
|
||||
"headers": headers,
|
||||
},
|
||||
)
|
||||
code = self.authentication_error_code()
|
||||
raise AuthenticationException(error_code=AUTHENTICATION_ERROR_CODES[code], error_message=str(code))
|
||||
|
||||
|
||||
@@ -7,11 +7,11 @@ import os
|
||||
|
||||
# Module imports
|
||||
from plane.authentication.adapter.credential import CredentialAdapter
|
||||
from plane.db.models import User
|
||||
from plane.authentication.adapter.error import (
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
AuthenticationException,
|
||||
)
|
||||
from plane.db.models import User
|
||||
from plane.license.utils.instance_value import get_configuration_value
|
||||
|
||||
|
||||
@@ -24,12 +24,14 @@ class EmailProvider(CredentialAdapter):
|
||||
self.code = code
|
||||
self.is_signup = is_signup
|
||||
|
||||
(ENABLE_EMAIL_PASSWORD,) = get_configuration_value([
|
||||
{
|
||||
"key": "ENABLE_EMAIL_PASSWORD",
|
||||
"default": os.environ.get("ENABLE_EMAIL_PASSWORD"),
|
||||
}
|
||||
])
|
||||
(ENABLE_EMAIL_PASSWORD,) = get_configuration_value(
|
||||
[
|
||||
{
|
||||
"key": "ENABLE_EMAIL_PASSWORD",
|
||||
"default": os.environ.get("ENABLE_EMAIL_PASSWORD"),
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
if ENABLE_EMAIL_PASSWORD == "0":
|
||||
raise AuthenticationException(
|
||||
@@ -41,29 +43,29 @@ class EmailProvider(CredentialAdapter):
|
||||
if self.is_signup:
|
||||
# Check if the user already exists
|
||||
if User.objects.filter(email=self.key).exists():
|
||||
self.logger.warning("User already exists")
|
||||
raise AuthenticationException(
|
||||
error_message="USER_ALREADY_EXIST",
|
||||
error_code=AUTHENTICATION_ERROR_CODES["USER_ALREADY_EXIST"],
|
||||
)
|
||||
|
||||
super().set_user_data({
|
||||
"email": self.key,
|
||||
"user": {
|
||||
"avatar": "",
|
||||
"first_name": "",
|
||||
"last_name": "",
|
||||
"provider_id": "",
|
||||
"is_password_autoset": False,
|
||||
},
|
||||
})
|
||||
super().set_user_data(
|
||||
{
|
||||
"email": self.key,
|
||||
"user": {
|
||||
"avatar": "",
|
||||
"first_name": "",
|
||||
"last_name": "",
|
||||
"provider_id": "",
|
||||
"is_password_autoset": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
return
|
||||
else:
|
||||
user = User.objects.filter(email=self.key).first()
|
||||
|
||||
# User does not exists
|
||||
if not user:
|
||||
self.logger.warning("User does not exist")
|
||||
raise AuthenticationException(
|
||||
error_message="USER_DOES_NOT_EXIST",
|
||||
error_code=AUTHENTICATION_ERROR_CODES["USER_DOES_NOT_EXIST"],
|
||||
@@ -72,7 +74,6 @@ class EmailProvider(CredentialAdapter):
|
||||
|
||||
# Check user password
|
||||
if not user.check_password(self.code):
|
||||
self.logger.warning("Authentication failed - invalid credentials")
|
||||
raise AuthenticationException(
|
||||
error_message=(
|
||||
"AUTHENTICATION_FAILED_SIGN_UP" if self.is_signup else "AUTHENTICATION_FAILED_SIGN_IN"
|
||||
@@ -83,14 +84,16 @@ class EmailProvider(CredentialAdapter):
|
||||
payload={"email": self.key},
|
||||
)
|
||||
|
||||
super().set_user_data({
|
||||
"email": self.key,
|
||||
"user": {
|
||||
"avatar": "",
|
||||
"first_name": "",
|
||||
"last_name": "",
|
||||
"provider_id": "",
|
||||
"is_password_autoset": False,
|
||||
},
|
||||
})
|
||||
super().set_user_data(
|
||||
{
|
||||
"email": self.key,
|
||||
"user": {
|
||||
"avatar": "",
|
||||
"first_name": "",
|
||||
"last_name": "",
|
||||
"provider_id": "",
|
||||
"is_password_autoset": False,
|
||||
},
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
@@ -10,14 +10,13 @@ from urllib.parse import urlencode
|
||||
import pytz
|
||||
import requests
|
||||
|
||||
from plane.authentication.adapter.error import (
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
AuthenticationException,
|
||||
)
|
||||
|
||||
# Module imports
|
||||
from plane.authentication.adapter.oauth import OauthAdapter
|
||||
from plane.license.utils.instance_value import get_configuration_value
|
||||
from plane.authentication.adapter.error import (
|
||||
AuthenticationException,
|
||||
AUTHENTICATION_ERROR_CODES,
|
||||
)
|
||||
|
||||
|
||||
class GitHubOAuthProvider(OauthAdapter):
|
||||
@@ -31,20 +30,22 @@ class GitHubOAuthProvider(OauthAdapter):
|
||||
organization_scope = "read:org"
|
||||
|
||||
def __init__(self, request, code=None, state=None, callback=None):
|
||||
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_ORGANIZATION_ID = get_configuration_value([
|
||||
{
|
||||
"key": "GITHUB_CLIENT_ID",
|
||||
"default": os.environ.get("GITHUB_CLIENT_ID"),
|
||||
},
|
||||
{
|
||||
"key": "GITHUB_CLIENT_SECRET",
|
||||
"default": os.environ.get("GITHUB_CLIENT_SECRET"),
|
||||
},
|
||||
{
|
||||
"key": "GITHUB_ORGANIZATION_ID",
|
||||
"default": os.environ.get("GITHUB_ORGANIZATION_ID"),
|
||||
},
|
||||
])
|
||||
GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_ORGANIZATION_ID = get_configuration_value(
|
||||
[
|
||||
{
|
||||
"key": "GITHUB_CLIENT_ID",
|
||||
"default": os.environ.get("GITHUB_CLIENT_ID"),
|
||||
},
|
||||
{
|
||||
"key": "GITHUB_CLIENT_SECRET",
|
||||
"default": os.environ.get("GITHUB_CLIENT_SECRET"),
|
||||
},
|
||||
{
|
||||
"key": "GITHUB_ORGANIZATION_ID",
|
||||
"default": os.environ.get("GITHUB_ORGANIZATION_ID"),
|
||||
},
|
||||
]
|
||||
)
|
||||
|
||||
if not (GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET):
|
||||
raise AuthenticationException(
|
||||
@@ -89,46 +90,32 @@ class GitHubOAuthProvider(OauthAdapter):
|
||||
"redirect_uri": self.redirect_uri,
|
||||
}
|
||||
token_response = self.get_user_token(data=data, headers={"Accept": "application/json"})
|
||||
super().set_token_data({
|
||||
"access_token": token_response.get("access_token"),
|
||||
"refresh_token": token_response.get("refresh_token", None),
|
||||
"access_token_expired_at": (
|
||||
datetime.fromtimestamp(token_response.get("expires_in"), tz=pytz.utc)
|
||||
if token_response.get("expires_in")
|
||||
else None
|
||||
),
|
||||
"refresh_token_expired_at": (
|
||||
datetime.fromtimestamp(token_response.get("refresh_token_expired_at"), tz=pytz.utc)
|
||||
if token_response.get("refresh_token_expired_at")
|
||||
else None
|
||||
),
|
||||
"id_token": token_response.get("id_token", ""),
|
||||
})
|
||||
super().set_token_data(
|
||||
{
|
||||
"access_token": token_response.get("access_token"),
|
||||
"refresh_token": token_response.get("refresh_token", None),
|
||||
"access_token_expired_at": (
|
||||
datetime.fromtimestamp(token_response.get("expires_in"), tz=pytz.utc)
|
||||
if token_response.get("expires_in")
|
||||
else None
|
||||
),
|
||||
"refresh_token_expired_at": (
|
||||
datetime.fromtimestamp(token_response.get("refresh_token_expired_at"), tz=pytz.utc)
|
||||
if token_response.get("refresh_token_expired_at")
|
||||
else None
|
||||
),
|
||||
"id_token": token_response.get("id_token", ""),
|
||||
}
|
||||
)
|
||||
|
||||
def __get_email(self, headers):
|
||||
try:
|
||||
# Github does not provide email in user response
|
||||
emails_url = "https://api.github.com/user/emails"
|
||||
emails_response = requests.get(emails_url, headers=headers).json()
|
||||
# Ensure the response is a list before iterating
|
||||
if not isinstance(emails_response, list):
|
||||
self.logger.error("Unexpected response format from GitHub emails API")
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"],
|
||||
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
|
||||
)
|
||||
email = next((email["email"] for email in emails_response if email["primary"]), None)
|
||||
if not email:
|
||||
self.logger.error("No primary email found for user")
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"],
|
||||
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
|
||||
)
|
||||
return email
|
||||
except requests.RequestException:
|
||||
self.logger.warning(
|
||||
"Error getting email from GitHub",
|
||||
)
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["GITHUB_OAUTH_PROVIDER_ERROR"],
|
||||
error_message="GITHUB_OAUTH_PROVIDER_ERROR",
|
||||
@@ -151,33 +138,22 @@ class GitHubOAuthProvider(OauthAdapter):
|
||||
|
||||
if self.organization_id:
|
||||
if not self.is_user_in_organization(user_info_response.get("login")):
|
||||
self.logger.warning(
|
||||
"User is not in organization",
|
||||
extra={
|
||||
"organization_id": self.organization_id,
|
||||
"user_login": user_info_response.get("login"),
|
||||
},
|
||||
)
|
||||
raise AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["GITHUB_USER_NOT_IN_ORG"],
|
||||
error_message="GITHUB_USER_NOT_IN_ORG",
|
||||
)
|
||||
|
||||
email = self.__get_email(headers=headers)
|
||||
self.logger.debug(
|
||||
"Email found",
|
||||
extra={
|
||||
super().set_user_data(
|
||||
{
|
||||
"email": email,
|
||||
},
|
||||
"user": {
|
||||
"provider_id": user_info_response.get("id"),
|
||||
"email": email,
|
||||
"avatar": user_info_response.get("avatar_url"),
|
||||
"first_name": user_info_response.get("name"),
|
||||
"last_name": user_info_response.get("family_name"),
|
||||
"is_password_autoset": True,
|
||||
},
|
||||
}
|
||||
)
|
||||
super().set_user_data({
|
||||
"email": email,
|
||||
"user": {
|
||||
"provider_id": user_info_response.get("id"),
|
||||
"email": email,
|
||||
"avatar": user_info_response.get("avatar_url"),
|
||||
"first_name": user_info_response.get("name"),
|
||||
"last_name": user_info_response.get("family_name"),
|
||||
"is_password_autoset": True,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -145,8 +145,8 @@ class ResetPasswordEndpoint(View):
|
||||
results = zxcvbn(password)
|
||||
if results["score"] < 3:
|
||||
exc = AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_TOO_WEAK"],
|
||||
error_message="PASSWORD_TOO_WEAK",
|
||||
error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"],
|
||||
error_message="INVALID_PASSWORD",
|
||||
)
|
||||
url = urljoin(
|
||||
base_host(request=request, is_app=True),
|
||||
|
||||
@@ -83,8 +83,8 @@ class ChangePasswordEndpoint(APIView):
|
||||
results = zxcvbn(new_password)
|
||||
if results["score"] < 3:
|
||||
exc = AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_TOO_WEAK"],
|
||||
error_message="PASSWORD_TOO_WEAK",
|
||||
error_code=AUTHENTICATION_ERROR_CODES["INVALID_NEW_PASSWORD"],
|
||||
error_message="INVALID_NEW_PASSWORD",
|
||||
)
|
||||
return Response(exc.get_error_dict(), status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
@@ -139,8 +139,8 @@ class ResetPasswordSpaceEndpoint(View):
|
||||
results = zxcvbn(password)
|
||||
if results["score"] < 3:
|
||||
exc = AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_TOO_WEAK"],
|
||||
error_message="PASSWORD_TOO_WEAK",
|
||||
error_code=AUTHENTICATION_ERROR_CODES["INVALID_PASSWORD"],
|
||||
error_message="INVALID_PASSWORD",
|
||||
)
|
||||
url = f"{base_host(request=request, is_space=True)}/accounts/reset-password/?{urlencode(exc.get_error_dict())}" # noqa: E501
|
||||
return HttpResponseRedirect(url)
|
||||
|
||||
@@ -13,6 +13,7 @@ from celery import shared_task
|
||||
# Django imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from django.db.models import Q, Case, Value, When
|
||||
from django.db import models
|
||||
from django.db.models.functions import Concat
|
||||
@@ -21,7 +22,6 @@ from django.db.models.functions import Concat
|
||||
from plane.db.models import Issue
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.analytics_plot import build_graph_plot
|
||||
from plane.utils.email import generate_plain_text_from_html
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
from plane.utils.csv_utils import sanitize_csv_row
|
||||
@@ -53,7 +53,7 @@ def send_export_email(email, slug, csv_buffer, rows):
|
||||
"""Helper function to send export email."""
|
||||
subject = "Your Export is ready"
|
||||
html_content = render_to_string("emails/exports/analytics.html", {})
|
||||
text_content = generate_plain_text_from_html(html_content)
|
||||
text_content = strip_tags(html_content)
|
||||
|
||||
csv_buffer.seek(0)
|
||||
|
||||
|
||||
@@ -15,12 +15,12 @@ from django.template.loader import render_to_string
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
from django.utils.html import strip_tags
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import EmailNotificationLog, Issue, User
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.settings.redis import redis_instance
|
||||
from plane.utils.email import generate_plain_text_from_html
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@@ -260,7 +260,7 @@ def send_email_notification(issue_id, notification_data, receiver_id, email_noti
|
||||
"entity_type": "issue",
|
||||
}
|
||||
html_content = render_to_string("emails/notifications/issue-updates.html", context)
|
||||
text_content = generate_plain_text_from_html(html_content)
|
||||
text_content = strip_tags(html_content)
|
||||
|
||||
try:
|
||||
connection = get_connection(
|
||||
|
||||
@@ -12,10 +12,10 @@ from celery import shared_task
|
||||
# Third party imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
|
||||
# Module imports
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.email import generate_plain_text_from_html
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ def forgot_password(first_name, email, uidb64, token, current_site):
|
||||
|
||||
html_content = render_to_string("emails/auth/forgot_password.html", context)
|
||||
|
||||
text_content = generate_plain_text_from_html(html_content)
|
||||
text_content = strip_tags(html_content)
|
||||
|
||||
connection = get_connection(
|
||||
host=EMAIL_HOST,
|
||||
|
||||
@@ -12,10 +12,10 @@ from celery import shared_task
|
||||
# Third party imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
|
||||
# Module imports
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.email import generate_plain_text_from_html
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ def magic_link(email, key, token):
|
||||
context = {"code": token, "email": email}
|
||||
|
||||
html_content = render_to_string("emails/auth/magic_signin.html", context)
|
||||
text_content = generate_plain_text_from_html(html_content)
|
||||
text_content = strip_tags(html_content)
|
||||
|
||||
connection = get_connection(
|
||||
host=EMAIL_HOST,
|
||||
|
||||
@@ -5,69 +5,37 @@
|
||||
# Python imports
|
||||
import json
|
||||
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
|
||||
# Django imports
|
||||
from django.utils import timezone
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Page, PageVersion
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
PAGE_VERSION_TASK_TIMEOUT = 600
|
||||
|
||||
@shared_task
|
||||
def track_page_version(page_id, existing_instance, user_id):
|
||||
def page_version(page_id, existing_instance, user_id):
|
||||
try:
|
||||
# Get the page
|
||||
page = Page.objects.get(id=page_id)
|
||||
|
||||
# Get the current instance
|
||||
current_instance = json.loads(existing_instance) if existing_instance is not None else {}
|
||||
sub_pages = {}
|
||||
|
||||
|
||||
# Create a version if description_html is updated
|
||||
if current_instance.get("description_html") != page.description_html:
|
||||
# Fetch the latest page version
|
||||
page_version = PageVersion.objects.filter(page_id=page_id).order_by("-last_saved_at").first()
|
||||
# Create a new page version
|
||||
PageVersion.objects.create(
|
||||
page_id=page_id,
|
||||
workspace_id=page.workspace_id,
|
||||
description_html=page.description_html,
|
||||
description_binary=page.description_binary,
|
||||
owned_by_id=user_id,
|
||||
last_saved_at=page.updated_at,
|
||||
description_json=page.description_json,
|
||||
description_stripped=page.description_stripped,
|
||||
)
|
||||
|
||||
# Get the latest page version if it exists and is owned by the user
|
||||
if (
|
||||
page_version
|
||||
and str(page_version.owned_by_id) == str(user_id)
|
||||
and (timezone.now() - page_version.last_saved_at).total_seconds() <= PAGE_VERSION_TASK_TIMEOUT
|
||||
):
|
||||
page_version.description_html = page.description_html
|
||||
page_version.description_binary = page.description_binary
|
||||
page_version.description_json = page.description
|
||||
page_version.description_stripped = page.description_stripped
|
||||
page_version.sub_pages_data = sub_pages
|
||||
page_version.save(
|
||||
update_fields=[
|
||||
"description_html",
|
||||
"description_binary",
|
||||
"description_json",
|
||||
"description_stripped",
|
||||
"sub_pages_data",
|
||||
"updated_at"
|
||||
]
|
||||
)
|
||||
else:
|
||||
# Create a new page version
|
||||
PageVersion.objects.create(
|
||||
page_id=page_id,
|
||||
workspace_id=page.workspace_id,
|
||||
description_json=page.description,
|
||||
description_html=page.description_html,
|
||||
description_binary=page.description_binary,
|
||||
description_stripped=page.description_stripped,
|
||||
owned_by_id=user_id,
|
||||
last_saved_at=timezone.now(),
|
||||
sub_pages_data=sub_pages,
|
||||
)
|
||||
# If page versions are greater than 20 delete the oldest one
|
||||
if PageVersion.objects.filter(page_id=page_id).count() > 20:
|
||||
# Delete the old page version
|
||||
|
||||
@@ -11,11 +11,11 @@ from celery import shared_task
|
||||
# Third party imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
|
||||
|
||||
# Module imports
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.email import generate_plain_text_from_html
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.db.models import ProjectMember
|
||||
from plane.db.models import User
|
||||
@@ -59,7 +59,7 @@ def project_add_user_email(current_site, project_member_id, invitor_id):
|
||||
|
||||
# Render the email template
|
||||
html_content = render_to_string("emails/notifications/project_addition.html", context)
|
||||
text_content = generate_plain_text_from_html(html_content)
|
||||
text_content = strip_tags(html_content)
|
||||
# Initialize the connection
|
||||
connection = get_connection(
|
||||
host=EMAIL_HOST,
|
||||
|
||||
@@ -12,11 +12,11 @@ from celery import shared_task
|
||||
# Third party imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import Project, ProjectMemberInvite, User
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.email import generate_plain_text_from_html
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@@ -37,12 +37,11 @@ def project_invitation(email, project_id, token, current_site, invitor):
|
||||
"first_name": user.first_name,
|
||||
"project_name": project.name,
|
||||
"invitation_url": abs_url,
|
||||
"current_site": current_site,
|
||||
}
|
||||
|
||||
html_content = render_to_string("emails/invitations/project_invitation.html", context)
|
||||
|
||||
text_content = generate_plain_text_from_html(html_content)
|
||||
text_content = strip_tags(html_content)
|
||||
|
||||
project_member_invite.message = text_content
|
||||
project_member_invite.save()
|
||||
|
||||
@@ -8,6 +8,7 @@ import logging
|
||||
# Django imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
@@ -15,7 +16,6 @@ from celery import shared_task
|
||||
# Module imports
|
||||
from plane.db.models import User
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.email import generate_plain_text_from_html
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ def user_activation_email(current_site, user_id):
|
||||
# Send email to user
|
||||
html_content = render_to_string("emails/user/user_activation.html", context)
|
||||
|
||||
text_content = generate_plain_text_from_html(html_content)
|
||||
text_content = strip_tags(html_content)
|
||||
# Configure email connection from the database
|
||||
(
|
||||
EMAIL_HOST,
|
||||
|
||||
@@ -8,6 +8,7 @@ import logging
|
||||
# Django imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
|
||||
# Third party imports
|
||||
from celery import shared_task
|
||||
@@ -15,7 +16,6 @@ from celery import shared_task
|
||||
# Module imports
|
||||
from plane.db.models import User
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.email import generate_plain_text_from_html
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ def user_deactivation_email(current_site, user_id):
|
||||
# Send email to user
|
||||
html_content = render_to_string("emails/user/user_deactivation.html", context)
|
||||
|
||||
text_content = generate_plain_text_from_html(html_content)
|
||||
text_content = strip_tags(html_content)
|
||||
# Configure email connection from the database
|
||||
(
|
||||
EMAIL_HOST,
|
||||
|
||||
@@ -11,10 +11,10 @@ from celery import shared_task
|
||||
# Django imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
|
||||
# Module imports
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.email import generate_plain_text_from_html
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ def send_email_update_magic_code(email, token):
|
||||
context = {"code": token, "email": email}
|
||||
|
||||
html_content = render_to_string("emails/auth/magic_signin.html", context)
|
||||
text_content = generate_plain_text_from_html(html_content)
|
||||
text_content = strip_tags(html_content)
|
||||
|
||||
connection = get_connection(
|
||||
host=EMAIL_HOST,
|
||||
@@ -87,7 +87,7 @@ def send_email_update_confirmation(email):
|
||||
context = {"email": email}
|
||||
|
||||
html_content = render_to_string("emails/user/email_updated.html", context)
|
||||
text_content = generate_plain_text_from_html(html_content)
|
||||
text_content = strip_tags(html_content)
|
||||
|
||||
connection = get_connection(
|
||||
host=EMAIL_HOST,
|
||||
|
||||
@@ -20,6 +20,7 @@ from django.db.models import Prefetch
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
|
||||
# Module imports
|
||||
@@ -50,7 +51,6 @@ from plane.db.models import (
|
||||
IssueAssignee,
|
||||
)
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.email import generate_plain_text_from_html
|
||||
from plane.utils.exception_logger import log_exception
|
||||
from plane.settings.mongo import MongoConnection
|
||||
|
||||
@@ -222,7 +222,7 @@ def send_webhook_deactivation_email(webhook_id: str, receiver_id: str, current_s
|
||||
"webhook_url": f"{current_site}/{str(webhook.workspace.slug)}/settings/webhooks/{str(webhook.id)}",
|
||||
}
|
||||
html_content = render_to_string("emails/notifications/webhook-deactivate.html", context)
|
||||
text_content = generate_plain_text_from_html(html_content)
|
||||
text_content = strip_tags(html_content)
|
||||
|
||||
# Set the email connection
|
||||
connection = get_connection(
|
||||
|
||||
@@ -11,11 +11,11 @@ from celery import shared_task
|
||||
# Django imports
|
||||
from django.core.mail import EmailMultiAlternatives, get_connection
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
|
||||
# Module imports
|
||||
from plane.db.models import User, Workspace, WorkspaceMemberInvite
|
||||
from plane.license.utils.instance_value import get_email_configuration
|
||||
from plane.utils.email import generate_plain_text_from_html
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ def workspace_invitation(email, workspace_id, token, current_site, inviter):
|
||||
|
||||
# Relative link
|
||||
relative_link = (
|
||||
f"/workspace-invitations/?invitation_id={workspace_member_invite.id}&slug={workspace.slug}&token={token}" # noqa: E501
|
||||
f"/workspace-invitations/?invitation_id={workspace_member_invite.id}&email={email}&slug={workspace.slug}" # noqa: E501
|
||||
)
|
||||
|
||||
# The complete url including the domain
|
||||
@@ -57,7 +57,7 @@ def workspace_invitation(email, workspace_id, token, current_site, inviter):
|
||||
|
||||
html_content = render_to_string("emails/invitations/workspace_invitation.html", context)
|
||||
|
||||
text_content = generate_plain_text_from_html(html_content)
|
||||
text_content = strip_tags(html_content)
|
||||
|
||||
workspace_member_invite.message = text_content
|
||||
workspace_member_invite.save()
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# Generated by Django 4.2.28 on 2026-02-17 10:47
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('db', '0119_alter_estimatepoint_key'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='issueview',
|
||||
name='archived_at',
|
||||
field=models.DateTimeField(null=True),
|
||||
),
|
||||
]
|
||||
@@ -3,7 +3,7 @@
|
||||
# See the LICENSE file for details.
|
||||
|
||||
# Django imports
|
||||
from django.core.validators import MinValueValidator
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
from django.db.models import Q
|
||||
|
||||
|
||||
@@ -140,8 +140,6 @@ class Project(BaseModel):
|
||||
"""Return name of the project"""
|
||||
return f"{self.name} <{self.workspace.name}>"
|
||||
|
||||
FORBIDDEN_IDENTIFIER_CHARS_PATTERN = r"^.*[&+,:;$^}{*=?@#|'<>.()%!-].*$"
|
||||
|
||||
class Meta:
|
||||
unique_together = [
|
||||
["identifier", "workspace", "deleted_at"],
|
||||
|
||||
@@ -68,7 +68,6 @@ class IssueView(WorkspaceBaseModel):
|
||||
logo_props = models.JSONField(default=dict)
|
||||
owned_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="views")
|
||||
is_locked = models.BooleanField(default=False)
|
||||
archived_at = models.DateTimeField(null=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Issue View"
|
||||
|
||||
@@ -191,8 +191,8 @@ class InstanceAdminSignUpEndpoint(View):
|
||||
results = zxcvbn(password)
|
||||
if results["score"] < 3:
|
||||
exc = AuthenticationException(
|
||||
error_code=AUTHENTICATION_ERROR_CODES["PASSWORD_TOO_WEAK"],
|
||||
error_message="PASSWORD_TOO_WEAK",
|
||||
error_code=AUTHENTICATION_ERROR_CODES["INVALID_ADMIN_PASSWORD"],
|
||||
error_message="INVALID_ADMIN_PASSWORD",
|
||||
payload={
|
||||
"email": email,
|
||||
"first_name": first_name,
|
||||
|
||||
@@ -80,11 +80,6 @@ LOGGING = {
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
"plane.authentication": {
|
||||
"level": "INFO",
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
"plane.migrations": {
|
||||
"level": "INFO",
|
||||
"handlers": ["console"],
|
||||
|
||||
@@ -90,11 +90,6 @@ LOGGING = {
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
"plane.authentication": {
|
||||
"level": "DEBUG" if DEBUG else "INFO",
|
||||
"handlers": ["console"],
|
||||
"propagate": False,
|
||||
},
|
||||
"plane.migrations": {
|
||||
"level": "DEBUG" if DEBUG else "INFO",
|
||||
"handlers": ["console"],
|
||||
|
||||
@@ -67,11 +67,6 @@ class ProjectMembersEndpoint(BaseAPIView):
|
||||
|
||||
def get(self, request, anchor):
|
||||
deploy_board = DeployBoard.objects.filter(anchor=anchor).first()
|
||||
if not deploy_board:
|
||||
return Response(
|
||||
{"error": "Invalid anchor"},
|
||||
status=status.HTTP_404_NOT_FOUND,
|
||||
)
|
||||
|
||||
members = ProjectMember.objects.filter(
|
||||
project=deploy_board.project,
|
||||
@@ -80,7 +75,10 @@ class ProjectMembersEndpoint(BaseAPIView):
|
||||
).values(
|
||||
"id",
|
||||
"member",
|
||||
"member__first_name",
|
||||
"member__last_name",
|
||||
"member__display_name",
|
||||
"member__avatar",
|
||||
"project",
|
||||
"workspace",
|
||||
)
|
||||
return Response(members, status=status.HTTP_200_OK)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 946 B |
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.3 KiB |
@@ -1,42 +0,0 @@
|
||||
# SPDX-FileCopyrightText: 2023-present Plane Software, Inc.
|
||||
# SPDX-License-Identifier: LicenseRef-Plane-Commercial
|
||||
#
|
||||
# Licensed under the Plane Commercial License (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
# https://plane.so/legals/eula
|
||||
#
|
||||
# DO NOT remove or modify this notice.
|
||||
# NOTICE: Proprietary and confidential. Unauthorized use or distribution is prohibited.
|
||||
|
||||
# Python imports
|
||||
import re
|
||||
|
||||
# Django imports
|
||||
from django.utils.html import strip_tags
|
||||
|
||||
|
||||
def generate_plain_text_from_html(html_content):
|
||||
"""
|
||||
Generate clean plain text from HTML email template.
|
||||
Removes all HTML tags, CSS styles, and excessive whitespace.
|
||||
|
||||
Args:
|
||||
html_content (str): The HTML content to convert to plain text
|
||||
|
||||
Returns:
|
||||
str: Clean plain text without HTML tags, styles, or excessive whitespace
|
||||
"""
|
||||
# Remove style tags and their content
|
||||
html_content = re.sub(r"<style[^>]*>.*?</style>", "", html_content, flags=re.DOTALL | re.IGNORECASE)
|
||||
|
||||
# Strip HTML tags
|
||||
text_content = strip_tags(html_content)
|
||||
|
||||
# Remove excessive empty lines
|
||||
text_content = re.sub(r"\n\s*\n\s*\n+", "\n\n", text_content)
|
||||
|
||||
# Ensure there's a leading and trailing whitespace
|
||||
text_content = "\n\n" + text_content.lstrip().rstrip() + "\n\n"
|
||||
|
||||
return text_content
|
||||
@@ -1,7 +1,7 @@
|
||||
# base requirements
|
||||
|
||||
# django
|
||||
Django==4.2.29
|
||||
Django==4.2.28
|
||||
# rest framework
|
||||
djangorestframework==3.15.2
|
||||
# postgres
|
||||
|
||||
@@ -124,7 +124,7 @@
|
||||
<td class="nl2go-responsive-hide" width="20" style=" font-size: 0px; line-height: 1px; " > </td>
|
||||
<td align="left" valign="top" class="r17-i nl2go-default-textstyle" style=" color: #3b3f44; font-family: arial, helvetica, sans-serif; font-size: 16px; line-height: 1.5; text-align: left; " >
|
||||
<div>
|
||||
<p style="margin: 0"> <span style=" font-size: 12px; " >Note: Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our </span ><a href="https://forum.plane.so" target="_blank" style=" color: #0092ff; text-decoration: underline; " ><span style=" font-size: 12px; " >Forum</span ></a ><span style=" font-size: 12px; " > or </span ><a href="https://github.com/makeplane/plane" target="_blank" style=" color: #0092ff; text-decoration: underline; " ><span style=" font-size: 12px; " >GitHub</span ></a ><span style=" font-size: 12px; " >, and we will use your feedback to improve on our upcoming releases.</span > </p>
|
||||
<p style="margin: 0"> <span style=" font-size: 12px; " >Note: Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our </span ><a href="https://discord.gg/A92xrEGCge" target="_blank" style=" color: #0092ff; text-decoration: underline; " ><span style=" font-size: 12px; " >Discord</span ></a ><span style=" font-size: 12px; " > or </span ><a href="https://github.com/makeplane/plane" target="_blank" style=" color: #0092ff; text-decoration: underline; " ><span style=" font-size: 12px; " >GitHub</span ></a ><span style=" font-size: 12px; " >, and we will use your feedback to improve on our upcoming releases.</span > </p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="nl2go-responsive-hide" width="20" style=" font-size: 0px; line-height: 1px; " > </td>
|
||||
@@ -227,7 +227,7 @@
|
||||
<td height="5" width="8" style=" font-size: 5px; line-height: 5px; " > </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r26-i" style=" font-size: 0px; line-height: 0px; " > <a href="https://github.com/makeplane/plane" target="_blank" style=" color: #0092ff; text-decoration: underline; " > <img src="{{ current_site }}/static/logos/github_32px.png" width="32" border="0" class="" style=" display: block; width: 100%; " /></a> </td>
|
||||
<td class="r26-i" style=" font-size: 0px; line-height: 0px; " > <a href="https://github.com/makeplane/plane" target="_blank" style=" color: #0092ff; text-decoration: underline; " > <img src="https://sendinblue-templates.s3.eu-west-3.amazonaws.com/icons/rounded_colored/github_32px.png" width="32" border="0" class="" style=" display: block; width: 100%; " /></a> </td>
|
||||
<td class="nl2go-responsive-hide" width="8" style=" font-size: 0px; line-height: 1px; " > </td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide" >
|
||||
@@ -244,7 +244,7 @@
|
||||
<td height="5" width="8" style=" font-size: 5px; line-height: 5px; " > </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r26-i" style=" font-size: 0px; line-height: 0px; " > <a href="https://forum.plane.so" target="_blank" style=" color: #0092ff; text-decoration: underline; " > <img src="{{ current_site }}/static/logos/website_32px.png" width="32" border="0" class="" style=" display: block; width: 100%; " /></a> </td>
|
||||
<td class="r26-i" style=" font-size: 0px; line-height: 0px; " > <a href="https://discord.gg/A92xrEGCge" target="_blank" style=" color: #0092ff; text-decoration: underline; " > <img src="https://sendinblue-templates.s3.eu-west-3.amazonaws.com/icons/rounded_colored/discord_32px.png" width="32" border="0" class="" style=" display: block; width: 100%; " /></a> </td>
|
||||
<td class="nl2go-responsive-hide" width="8" style=" font-size: 0px; line-height: 1px; " > </td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide" >
|
||||
@@ -261,7 +261,7 @@
|
||||
<td height="5" width="8" style=" font-size: 5px; line-height: 5px; " > </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r26-i" style=" font-size: 0px; line-height: 0px; " > <a href="https://twitter.com/planepowers" target="_blank" style=" color: #0092ff; text-decoration: underline; " > <img src="{{ current_site }}/static/logos/twitter_32px.png" width="32" border="0" class="" style=" display: block; width: 100%; " /></a> </td>
|
||||
<td class="r26-i" style=" font-size: 0px; line-height: 0px; " > <a href="https://twitter.com/planepowers" target="_blank" style=" color: #0092ff; text-decoration: underline; " > <img src="https://sendinblue-templates.s3.eu-west-3.amazonaws.com/icons/rounded_colored/twitter_32px.png" width="32" border="0" class="" style=" display: block; width: 100%; " /></a> </td>
|
||||
<td class="nl2go-responsive-hide" width="8" style=" font-size: 0px; line-height: 1px; " > </td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide" >
|
||||
@@ -277,7 +277,7 @@
|
||||
<td height="5" style=" font-size: 5px; line-height: 5px; " > </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="r26-i" style=" font-size: 0px; line-height: 0px; " > <a href="https://www.linkedin.com/company/planepowers/" target="_blank" style=" color: #0092ff; text-decoration: underline; " > <img src="{{ current_site }}/static/logos/linkedin_32px.png" width="32" border="0" class="" style=" display: block; width: 100%; " /></a> </td>
|
||||
<td class="r26-i" style=" font-size: 0px; line-height: 0px; " > <a href="https://www.linkedin.com/company/planepowers/" target="_blank" style=" color: #0092ff; text-decoration: underline; " > <img src="https://sendinblue-templates.s3.eu-west-3.amazonaws.com/icons/rounded_colored/linkedin_32px.png" width="32" border="0" class="" style=" display: block; width: 100%; " /></a> </td>
|
||||
</tr>
|
||||
<tr class="nl2go-responsive-hide" >
|
||||
<td height="5" style=" font-size: 5px; line-height: 5px; " > </td>
|
||||
@@ -346,4 +346,4 @@
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user