Merge branch 'main' into search-page-markup

* main: (67 commits)
  Add jason@37signals.com to the seeds.
  Rework menu sizing
  Firefox has its own psuedo elements
  Allow pop-ups to overflow without breaking the layout
  Style the prompt empty state
  Update lexical
  Scope CSS to custom attachments
  Update to point to version with fix for attachments
  Update AR::Tenanted to address tenant creation raciness
  dep: Update signal_id to quash noisy test warnings
  Update dependences, including Rails and queenbee-plugin
  Update 400/422/500 error pages with latest stock Rails versions
  Remove subdomain listing from the 404 page
  Delete the first runs controller/routes/model
  Redirect unauthenticated users to Launchpad
  Update a few users' email to link to a valid identity.
  Make sure the beta env keeps local authentication (for now)
  Trampoline the Launchpad auth redirect from a GET into a POST
  Add the shared signal_id secret
  Script to create QB and SignalID accounts, and SignalID users
  ...
This commit is contained in:
Jorge Manrubia
2025-06-24 06:19:39 +02:00
105 changed files with 2128 additions and 539 deletions
+3 -2
View File
@@ -19,7 +19,8 @@ FROM base AS build
# Install packages needed to build gems
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y build-essential git libvips pkg-config libyaml-dev
apt-get install -y --no-install-recommends -y build-essential git libvips pkg-config libyaml-dev default-libmysqlclient-dev && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Install application gems
COPY Gemfile Gemfile.lock .ruby-version ./
@@ -47,7 +48,7 @@ FROM base
# Install packages needed for deployment
RUN apt-get update -qq && \
apt-get install --no-install-recommends -y curl libsqlite3-0 libvips build-essential ffmpeg groff libreoffice-writer libreoffice-impress libreoffice-calc mupdf-tools && \
apt-get install --no-install-recommends -y curl libsqlite3-0 libvips build-essential ffmpeg groff libreoffice-writer libreoffice-impress libreoffice-calc mupdf-tools default-libmysqlclient-dev && \
rm -rf /var/lib/apt/lists /var/cache/apt/archives
# Copy built artifacts: gems, application
+11
View File
@@ -32,6 +32,16 @@ gem "image_processing", "~> 1.14"
gem "platform_agent"
gem "aws-sdk-s3", require: false
# 37id and Queenbee integration
gem "signal_id", bc: "signal_id", branch: "rails4"
gem "mysql2" # needed by signal_id
gem "queuety", bc: "queuety", branch: "rails4" # needed by signal_id
gem "service_concurrency_prevention", bc: "service_concurrency_prevention" # needed by queuety
gem "portfolio", ">= 4.6", bc: "portfolio" # needed by signal_id
gem "file_repository", "~> 1.4.5", bc: "file_repository" # needed by portfolio
gem "queenbee", bc: "queenbee-plugin"
gem "activeresource", require: "active_resource" # needed by queenbee
# Telemetry, logging, and operations
gem "mission_control-jobs"
gem "sentry-ruby"
@@ -55,4 +65,5 @@ group :test do
gem "selenium-webdriver"
gem "webmock"
gem "vcr"
gem "mocha"
end
+102 -14
View File
@@ -1,13 +1,13 @@
GIT
remote: https://github.com/basecamp/actiontext-lexical
revision: 3f1ec3916f468ac2bb626091bcb97469c2c52483
revision: 79ac1958183afb1d8cecd6799d230b63392eb014
specs:
actiontext-lexical (0.1.0)
rails (>= 8.0.2)
GIT
remote: https://github.com/basecamp/active_record-tenanted
revision: f6fca189655ae6609d10ecad7cceed61592320c4
revision: 81a4d02a363fa749de517c90e19588365f934913
specs:
active_record-tenanted (0.1.0)
activerecord (>= 8.1.alpha)
@@ -15,6 +15,12 @@ GIT
railties (>= 8.1.alpha)
zeitwerk
GIT
remote: https://github.com/basecamp/file_repository
revision: 17e90fc35ccb9f0a0dae1ed8623f01386696002d
specs:
file_repository (1.4.5)
GIT
remote: https://github.com/basecamp/kamal
revision: 344e2d7995e0134aa52f195a984d8d5e24e31d94
@@ -32,6 +38,32 @@ GIT
thor (~> 1.3)
zeitwerk (>= 2.6.18, < 3.0)
GIT
remote: https://github.com/basecamp/portfolio
revision: 26dd0ccbe4aad5a5c539f90c00e82b6175443636
specs:
portfolio (5.2.0)
activesupport
file_repository (>= 1.4.4)
GIT
remote: https://github.com/basecamp/queenbee-plugin
revision: eb476e7f309b7642aa20254f0bdbadcec1dda2b2
specs:
queenbee (3.2.0)
activeresource
builder
rexml
GIT
remote: https://github.com/basecamp/queuety
revision: bf7e2a552fb674533d3e092f06dbf9b5d1ebc753
branch: rails4
specs:
queuety (4.0.2)
activerecord
service_concurrency_prevention
GIT
remote: https://github.com/basecamp/rails-structured-logging
revision: 76960cb5c15fc2b6b5f7542e05d7dcc031cef9e6
@@ -40,6 +72,29 @@ GIT
json
rails (>= 6.0.0)
GIT
remote: https://github.com/basecamp/service_concurrency_prevention
revision: 320da99431718615ed86f8723e8b9d3259c602a3
specs:
service_concurrency_prevention (1.1.1)
activesupport
GIT
remote: https://github.com/basecamp/signal_id
revision: 8908b2550ff55325b04c108146e00b4408a64e93
branch: rails4
specs:
signal_id (4.3.2)
bcrypt-ruby (>= 3.0.0)
google-id-token (>= 1.4.0)
mocha
multi_json
portfolio (>= 4.0)
pwned (~> 2.0)
queuety (>= 3.0)
rails (>= 4.0.2)
rails-controller-testing
GIT
remote: https://github.com/crmne/ruby_llm.git
revision: a35aa111a0bb36b7f0363b1f1f2a25e3d5378fd3
@@ -56,7 +111,7 @@ GIT
GIT
remote: https://github.com/rails/rails.git
revision: fd49d6a3180244969fb5ab9768d0bc7d9e4df9fe
revision: 53a9a3181a59f4e6dff73ba9453734edd7f7e076
branch: main
specs:
actioncable (8.1.0.alpha)
@@ -159,22 +214,30 @@ GEM
specs:
action_text-trix (2.1.15)
railties
activemodel-serializers-xml (1.0.3)
activemodel (>= 5.0.0.a)
activesupport (>= 5.0.0.a)
builder (~> 3.1)
activeresource (6.1.4)
activemodel (>= 6.0)
activemodel-serializers-xml (~> 1.0)
activesupport (>= 6.0)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
ast (2.4.3)
aws-eventstream (1.4.0)
aws-partitions (1.1115.0)
aws-sdk-core (3.225.2)
aws-partitions (1.1119.0)
aws-sdk-core (3.226.0)
aws-eventstream (~> 1, >= 1.3.0)
aws-partitions (~> 1, >= 1.992.0)
aws-sigv4 (~> 1.9)
base64
jmespath (~> 1, >= 1.6.1)
logger
aws-sdk-kms (1.104.0)
aws-sdk-kms (1.105.0)
aws-sdk-core (~> 3, >= 3.225.0)
aws-sigv4 (~> 1.5)
aws-sdk-s3 (1.189.1)
aws-sdk-s3 (1.190.0)
aws-sdk-core (~> 3, >= 3.225.0)
aws-sdk-kms (~> 1)
aws-sigv4 (~> 1.5)
@@ -182,6 +245,8 @@ GEM
aws-eventstream (~> 1, >= 1.0.2)
base64 (0.3.0)
bcrypt (3.1.20)
bcrypt-ruby (3.1.5)
bcrypt (>= 3.1.3)
bcrypt_pbkdf (1.1.1)
benchmark (0.4.1)
bigdecimal (3.2.2)
@@ -210,7 +275,7 @@ GEM
rexml
crass (1.0.6)
date (3.4.1)
debug (1.10.0)
debug (1.11.0)
irb (~> 1.10)
reline (>= 0.3.8)
dotenv (3.1.8)
@@ -243,6 +308,8 @@ GEM
addressable (>= 2.5.0)
globalid (1.2.1)
activesupport (>= 6.1)
google-id-token (1.4.2)
jwt (>= 1)
hashdiff (1.2.0)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
@@ -263,6 +330,8 @@ GEM
activesupport (>= 5.0.0)
jmespath (1.6.2)
json (2.12.2)
jwt (2.10.1)
base64
language_server-protocol (3.17.0.5)
lint_roller (1.1.0)
logger (1.7.0)
@@ -292,11 +361,15 @@ GEM
railties (>= 7.1)
stimulus-rails
turbo-rails
mocha (2.7.1)
ruby2_keywords (>= 0.0.5)
msgpack (1.8.0)
multi_json (1.15.0)
multipart-post (2.4.1)
mysql2 (0.5.6)
net-http (0.6.0)
uri
net-imap (0.5.8)
net-imap (0.5.9)
date
net-protocol
net-pop (0.1.2)
@@ -343,6 +416,7 @@ GEM
public_suffix (6.0.2)
puma (6.6.0)
nio4r (~> 2.0)
pwned (2.4.1)
raabro (1.4.0)
racc (1.8.1)
rack (3.1.16)
@@ -355,6 +429,10 @@ GEM
rack (>= 1.3)
rackup (2.2.1)
rack (>= 3)
rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1)
actionview (>= 5.0.1.rc1)
activesupport (>= 5.0.1.rc1)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
@@ -365,7 +443,7 @@ GEM
rainbow (3.1.1)
rake (13.3.0)
rb_sys (0.9.106)
rdoc (6.14.0)
rdoc (6.14.1)
erb
psych (>= 4.0.0)
redcarpet (3.6.1)
@@ -410,6 +488,7 @@ GEM
ruby-vips (2.2.3)
ffi (~> 1.12)
logger
ruby2_keywords (0.0.5)
rubyzip (2.4.1)
securerandom (0.4.1)
selenium-webdriver (4.33.0)
@@ -461,10 +540,10 @@ GEM
railties (>= 6.0.0)
stringio (3.1.7)
thor (1.3.2)
thruster (0.1.13)
thruster (0.1.13-arm64-darwin)
thruster (0.1.13-x86_64-darwin)
thruster (0.1.13-x86_64-linux)
thruster (0.1.14)
thruster (0.1.14-arm64-darwin)
thruster (0.1.14-x86_64-darwin)
thruster (0.1.14-x86_64-linux)
tiktoken_ruby (0.0.11.1)
rb_sys (= 0.9.106)
tiktoken_ruby (0.0.11.1-arm64-darwin)
@@ -506,6 +585,7 @@ PLATFORMS
DEPENDENCIES
actiontext-lexical!
active_record-tenanted!
activeresource
aws-sdk-s3
bcrypt (~> 3.1.7)
bootsnap
@@ -513,15 +593,21 @@ DEPENDENCIES
bundler-audit
capybara
debug
file_repository (~> 1.4.5)!
geared_pagination (~> 1.2)
image_processing (~> 1.14)
importmap-rails
jbuilder
kamal!
mission_control-jobs
mocha
mysql2
platform_agent
portfolio (>= 4.6)!
propshaft
puma (>= 5.0)
queenbee!
queuety!
rails!
rails_structured_logging!
redcarpet
@@ -532,6 +618,8 @@ DEPENDENCIES
selenium-webdriver
sentry-rails
sentry-ruby
service_concurrency_prevention!
signal_id!
solid_cable (>= 3.0)
solid_cache (~> 1.0)
solid_queue (~> 1.1)
+174 -109
View File
@@ -1,136 +1,201 @@
lexical-editor {
.node--selected {
&:not(:has(img)) {
box-shadow: 0 0 0 var(--hover-size) var(--hover-color);
@layer components {
lexical-editor {
display: block;
position: relative;
overflow: visible;
.node--selected:not(.attachment--custom) {
&:not(:has(img)) {
box-shadow: 0 0 0 var(--hover-size) var(--hover-color);
}
&:has(img) {
img {
box-shadow: 0 0 0 var(--hover-size) var(--hover-color);
}
}
}
&:has(img) {
img {
box-shadow: 0 0 0 var(--hover-size) var(--hover-color);
&.lexical-editor--empty {
.lexical-editor__content::before {
content: attr(placeholder);
color: currentColor;
cursor: text;
opacity: 0.66;
pointer-events: none;
position: absolute;
white-space: pre-line;
}
}
}
&.lexical-editor--empty {
.lexical-editor__content::before {
content: attr(placeholder);
color: currentColor;
cursor: text;
opacity: 0.66;
pointer-events: none;
position: absolute;
white-space: pre-line;
.lexical-dialog-actions {
display: flex;
font-size: var(--text-x-small);
flex: 1 1 0%;
gap: var(--inline-space-half);
margin-block-start: var(--block-space-half);
.btn {
--radius: 0.3em;
inline-size: 100%;
justify-content: center;
&:is([type="submit"]) {
--btn-background: var(--card-color, var(--color-link));
--btn-color: var(--color-ink-inverted);
--outline-color: var(--card-color, var(--color-link));
}
}
}
}
.lexical-dialog-actions {
display: flex;
font-size: var(--text-x-small);
flex: 1 1 0%;
gap: var(--inline-space-half);
margin-block-start: var(--block-space-half);
.btn {
--radius: 0.3em;
inline-size: 100%;
justify-content: center;
&:is([type="submit"]) {
--btn-background: var(--card-color, var(--color-link));
--btn-color: var(--color-ink-inverted);
--outline-color: var(--card-color, var(--color-link));
span {
inline-size: 100%;
}
}
span {
inline-size: 100%;
}
}
.lexical-editor__content {
--outline-size: max(2px, 0.08em);
.lexical-editor__content {
--outline-size: max(2px, 0.08em);
margin: var(--block-space-half) 0;
min-block-size: calc(7lh + var(--block-space));
outline: 0;
padding: var(--block-space-half) var(--inline-space);
margin: var(--block-space-half) 0;
min-block-size: calc(7lh + var(--block-space));
outline: 0;
padding: var(--block-space-half) var(--inline-space);
* {
&:first-child {
margin-block-start: 0;
}
* {
&:first-child {
margin-block-start: 0;
}
&:last-child {
margin-block-end: 0;
}
}
}
.lexical-editor--drag-over {
background-color: var(--color-selected);
border-radius: 4px;
outline: 2px dashed var(--color-selected-dark);
}
lexical-toolbar {
background-color: inherit;
border-block-end: 1px solid var(--color-ink-light);
color: currentColor;
display: flex;
font-size: inherit;
margin: 0;
max-inline-size: 100%;
padding: 0.2em 0;
position: relative;
dialog {
background-color: var(--color-canvas);
border: 1px solid var(--color-ink-lighter);
border-radius: 0.5em;
box-shadow: var(--shadow);
color: var(--color-ink);
padding: var(--block-space) calc(var(--inline-space) * 1.5);
position: absolute;
z-index: 1;
.input[type="url"] {
min-inline-size: 30ch;
&:last-child {
margin-block-end: 0;
}
}
}
> button {
aspect-ratio: 4/3.5;
appearance: none;
background-color: transparent;
block-size: 2em;
border: none;
border-radius: 0.2em;
.lexical-editor--drag-over {
background-color: var(--color-selected);
border-radius: 4px;
outline: 2px dashed var(--color-selected-dark);
}
lexical-toolbar {
background-color: inherit;
border-block-end: 1px solid var(--color-ink-light);
color: currentColor;
cursor: pointer;
display: grid;
display: flex;
font-size: inherit;
inline-size: auto;
place-items: center;
transition: background-color 300ms ease;
margin: 0;
max-inline-size: 100%;
padding: 0.2em 0;
position: relative;
svg {
-webkit-touch-callout: none;
block-size: 0.85em;
fill: currentColor;
grid-area: 1/1;
dialog {
background-color: var(--color-canvas);
border: 1px solid var(--color-ink-lighter);
border-radius: 0.5em;
box-shadow: var(--shadow);
color: var(--color-ink);
padding: var(--block-space) calc(var(--inline-space) * 1.5);
position: absolute;
z-index: 1;
.input[type="url"] {
min-inline-size: 30ch;
}
}
> button {
aspect-ratio: 4/3.5;
appearance: none;
background-color: transparent;
block-size: 2em;
border: none;
border-radius: 0.2em;
color: currentColor;
cursor: pointer;
display: grid;
font-size: inherit;
inline-size: auto;
user-select: none;
}
place-items: center;
transition: background-color 300ms ease;
&:is(:focus, :hover) {
background-color: var(--color-ink-lighter);
box-shadow: none;
}
svg {
-webkit-touch-callout: none;
block-size: 0.85em;
fill: currentColor;
grid-area: 1/1;
inline-size: auto;
user-select: none;
}
&:is(:active) {
&:is(:focus, :hover) {
background-color: var(--color-ink-lighter);
box-shadow: none;
}
&:is(:active) {
background-color: var(--color-selected);
}
}
}
/* Prompt
/* ------------------------------------------------------------------------ */
.lexical-prompt-menu {
--lexical-prompt-avatar-size: 24px;
--lexical-prompt-min-width: 20ch;
--lexical-prompt-padding: 0.5ch;
background-color: var(--color-canvas);
border-radius: calc(var(--lexical-prompt-padding) * 2);
box-shadow: var(--shadow);
font-size: var(--text-small);
list-style: none;
margin: 0;
min-inline-size: var(--lexical-prompt-min-width);
padding: var(--lexical-prompt-padding);
visibility: hidden;
z-index: var(--z-popup);
}
.lexical-prompt-menu--visible {
visibility: initial;
}
.lexical-prompt-menu__item {
align-items: center;
border-radius: 0.5ch;
cursor: pointer;
display: flex;
gap: var(--lexical-prompt-padding);
padding: var(--lexical-prompt-padding);
&:hover {
background-color: var(--color-selected);
}
&[aria-selected] {
box-shadow: 0 0 0 var(--hover-size) var(--color-selected-dark) inset;
&:hover {
--hover-size: 0;
}
}
img {
block-size: var(--lexical-prompt-avatar-size);
border-radius: 50%;
flex-shrink: 0;
inline-size: var(--lexical-prompt-avatar-size);
margin: 0;
}
}
/* Empty state */
.lexical-prompt-menu__item--empty {
color: var(--color-ink-medium);
padding: var(--lexical-prompt-padding);
}
}
+1 -1
View File
@@ -90,7 +90,7 @@
border-radius: 0.3em;
box-shadow: 0 0.1em 0 currentColor;
font-family: var(--font-mono);
font-size: var(--text-x-small);
font-size: 0.8em;
font-weight: 600;
opacity: 0.7;
padding: 0 0.4em;
-38
View File
@@ -225,43 +225,5 @@
inline-size: 100%;
}
}
.btn__group--chunky {
--gap: 0.2em;
gap: var(--gap);
margin: var(--block-space-half) auto calc(var(--block-space-half) / 2);
.btn {
--btn-border-radius: 0.4em;
align-content: end;
aspect-ratio: 5/3;
flex-basis: calc(33% - var(--gap) / 2);
flex-direction: column;
font-size: var(--text-small);
line-height: 1;
justify-content: center;
position: relative;
row-gap: 0.3lh;
text-align: center;
kbd {
border: none;
box-shadow: none;
inset: 0.66em 0.33em auto auto;
opacity: 0.5;
position: absolute;
}
.icon {
--icon-size: 2em !important;
}
span {
display: flex;
}
}
}
}
+1
View File
@@ -86,6 +86,7 @@
}
.card__content {
contain: inline-size;
flex: 2 1 auto;
max-inline-size: 100%;
}
-1
View File
@@ -1,7 +1,6 @@
@layer components {
:is(.dialog) {
--backdrop-speed: 150ms;
--panel-size: max-content;
--speed: 150ms;
border: 0;
+66 -22
View File
@@ -1,28 +1,72 @@
.fizzy-dialog {
--panel-border-color: var(--color-selected-dark);
--panel-border-radius: 1.4em;
@layer components {
.fizzy-dialog:is(.panel) {
--panel-border-color: var(--color-selected-dark);
--panel-border-radius: 1.4em;
--panel-size: 45ch;
box-shadow: 0 0 0 1px oklch(var(--lch-blue-medium) / 5%),
0 0.2em 0.2em oklch(var(--lch-blue-medium) / 5%),
0 0.4em 0.4em oklch(var(--lch-blue-medium) / 5%),
0 0.8em 0.8em oklch(var(--lch-blue-medium) / 5%);
z-index: 5;
}
box-shadow: 0 0 0 1px oklch(var(--lch-blue-medium) / 5%),
0 0.2em 0.2em oklch(var(--lch-blue-medium) / 5%),
0 0.4em 0.4em oklch(var(--lch-blue-medium) / 5%),
0 0.8em 0.8em oklch(var(--lch-blue-medium) / 5%);
}
.fizzy-menu {
--input-border-color: var(--color-selected-dark);
--input-padding: 0.2em 2em 0.2em 0.9em;
.input:is(.fizzy-menu) {
--input-border-color: var(--color-selected-dark);
--input-padding: 0.2em 2em 0.2em 0.9em;
box-shadow: 0 0 0 1px oklch(var(--lch-blue-medium) / 5%),
0 0.2em 0.2em oklch(var(--lch-blue-medium) / 5%),
0 0.4em 0.4em oklch(var(--lch-blue-medium) / 5%),
0 0.8em 0.8em oklch(var(--lch-blue-medium) / 5%);
margin-block-start: 0.1em;
box-shadow: 0 0 0 1px oklch(var(--lch-blue-medium) / 5%),
0 0.2em 0.2em oklch(var(--lch-blue-medium) / 5%),
0 0.4em 0.4em oklch(var(--lch-blue-medium) / 5%),
0 0.8em 0.8em oklch(var(--lch-blue-medium) / 5%);
margin-block-start: 0.1em;
svg {
block-size: auto;
inline-size: 1.3em;
margin-block-end: 0.2em;
margin-inline-end: 0.8ch;
svg {
block-size: auto;
inline-size: 1.3em;
margin-block-end: 0.2em;
margin-inline-end: 0.8ch;
}
}
.fizzy-menu__hotkeys {
--gap: 8px;
gap: var(--gap);
margin: var(--block-space) auto calc(var(--block-space-half) / 2);
.btn {
--btn-border-radius: 0.4em;
align-content: end;
aspect-ratio: 5/3;
background-color: var(--color-ink-lightest);
border-radius: 0.5em;
flex-basis: calc((100% - var(--gap) * 2) / 3);
flex-direction: column;
font-size: var(--text-small);
line-height: 1;
justify-content: center;
overflow: hidden;
position: relative;
row-gap: 0.3lh;
text-align: center;
kbd {
border: none;
box-shadow: none;
inset: 0.66em 0.33em auto auto;
opacity: 0.5;
position: absolute;
}
.icon {
--icon-size: 2em !important;
}
span {
display: flex;
text-wrap: nowrap;
}
}
}
}
+11
View File
@@ -30,11 +30,22 @@
translate: -50% -50%;
z-index: 1;
&::-moz-range-track {
block-size: var(--knob-size);
cursor: grab;
}
&::-webkit-slider-runnable-track {
block-size: var(--knob-size);
cursor: grab;
}
&::-moz-range-thumb {
background-color: transparent;
border: none;
border-radius: 0;
}
&::-webkit-slider-thumb {
appearance: none;
background-color: transparent;
+31 -17
View File
@@ -1,14 +1,13 @@
@layer components {
.popup {
--panel-border-radius: 0.5em;
--panel-padding: var(--block-space) var(--block-space) 0 var(--block-space);
--panel-padding: var(--block-space);
--panel-size: auto;
inline-size: auto;
inset: 0 auto auto 50%;
max-block-size: 80vh;
min-inline-size: min(25ch, 90vw);
max-inline-size: min(55ch, 90vw);
min-inline-size: min(25ch, calc(100dvw - var(--block-space-double)));
max-inline-size: min(55ch, calc(100dvw - var(--block-space-double)));
overflow: auto;
position: absolute;
transform: translateX(-50%);
@@ -18,15 +17,14 @@
display: flex;
}
&:has(.popup__footer) {
--panel-padding: var(--block-space) var(--block-space) 0 var(--block-space)
}
#header:has(&) {
max-inline-size: 100vw;
position: relative;
z-index: 5;
.popup {
min-inline-size: 40ch;
max-inline-size: 40ch;
}
}
.card & {
@@ -37,14 +35,19 @@
.popup__footer {
background-color: var(--color-canvas);
inset: auto 0 0 0;
padding: var(--block-space);
border-block-start: 1px solid var(--color-ink-lighter);
font-size: var(--text-small);
inset: auto 0 0;
margin-inline: calc(var(--block-space) * -1);
padding: var(--block-space-half) var(--inline-space) var(--block-space);
position: sticky;
text-align: center;
z-index: 1;
}
.popup__group {
background: var(--color-ink-lightest);
background: transparent;
border-radius: 0.3em;
max-inline-size: 100%;
padding-inline: var(--inline-space);
@@ -61,13 +64,12 @@
}
&[aria-selected] {
border-radius: 0.3em;
box-shadow: 0 0 0 var(--hover-size) var(--color-selected-dark) inset;
}
}
.popup__item {
--btn-background: var(--color-ink-lightest);
--btn-background: transparent;
--btn-border-radius: 0.3em;
--btn-border-size: 0;
--btn-font-weight: 500;
@@ -114,27 +116,39 @@
}
.popup__list {
display: flex;
flex-direction: column;
inline-size: 100%;
margin: 0;
max-inline-size: 100%;
padding: 0;
row-gap: 2px;
}
.popup__new {
--btn-icon-size: 1em;
padding-inline-start: var(--inline-space-half);
.icon {
margin-inline-end: 0.5em;
}
padding-inline-start: var(--inline-space-half);
}
.popup__title {
font-weight: 800;
}
.popup__group-title {
font-size: var(--text-small);
margin-block: var(--block-space-half);
padding-inline: var(--inline-space);
text-transform: uppercase;
}
}
.popup--animated {
--backdrop-speed: 150ms;
--panel-size: max-content;
--speed: 150ms;
opacity: 0;
+41 -11
View File
@@ -20,7 +20,7 @@
:where(h5) { font-size: 0.83em; }
:where(h6) { font-size: 0.67em; }
:where(p, ul, ol, dl, blockquote, figure, .attachment) {
:where(p, ul, ol, dl, blockquote, figure:not(.attachment--custom), .attachment:not(.attachment--custom)) {
margin-block: var(--block-margin);
overflow-wrap: break-word;
text-wrap: pretty;
@@ -54,7 +54,7 @@
}
/* Attachments */
.attachment {
.attachment:not(.attachment--custom) {
block-size: auto;
display: inline-block;
inline-size: 100%;
@@ -220,15 +220,6 @@
text-decoration: underline;
}
/* Attachment junk gets wrapped in a paragraph causing unwanted space */
p:has(action-text-attachment) {
display: none;
+ * {
margin-block-start: 0;
}
}
/* Avoid extra space due to empty paragraphs */
p:empty {
display: none;
@@ -329,5 +320,44 @@
}
}
}
/* Custom attachments such as mentions, etc. */
.attachment--custom {
display: inline;
padding: 0;
margin: 0;
white-space: normal;
}
.attachment--custom,
action-text-attachment[content-type^='application/vnd.actiontext'] {
--attachment-image-size: 1em;
--attachment-selected-color: oklch(var(--lch-blue-dark));
align-items: center;
display: inline-flex;
gap: 0.25ch;
position: relative;
vertical-align: bottom;
lexical-editor & {
cursor: pointer;
}
img {
block-size: var(--attachment-image-size);
border-radius: 50%;
inline-size: var(--attachment-image-size);
}
&.node--selected {
background: var(--attachment-selected-color);
box-shadow:
-0.25ch 0 0 var(--attachment-selected-color),
0.5ch 0 0 var(--attachment-selected-color);
border-radius: 99rem;
color: var(--color-ink-inverted);
}
}
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
class ApplicationController < ActionController::Base
include Authentication, CurrentTimezone, SetPlatform, WriterAffinity
include Authentication, CurrentRequest, CurrentTimezone, SetPlatform, WriterAffinity
stale_when_importmap_changes
allow_browser versions: :modern
+20 -1
View File
@@ -2,6 +2,7 @@ module Authentication
extend ActiveSupport::Concern
included do
before_action :set_current_account
before_action :require_authentication
helper_method :authenticated?
end
@@ -16,6 +17,12 @@ module Authentication
skip_before_action :require_authentication, **options
before_action :resume_session, **options
end
def require_untenanted_access(**options)
skip_before_action :set_current_account, **options
skip_before_action :require_authentication, **options
before_action :redirect_tenanted_request, **options
end
end
private
@@ -40,7 +47,12 @@ module Authentication
def request_authentication
session[:return_to_after_authenticating] = request.url
redirect_to new_session_path
if Rails.application.config.x.local_authentication
redirect_to new_session_path
else
redirect_to Launchpad.login_url(account: Current.account), allow_other_host: true
end
end
def after_authentication_url
@@ -51,6 +63,9 @@ module Authentication
redirect_to root_url if authenticated?
end
def redirect_tenanted_request
redirect_to root_url if ApplicationRecord.current_tenant
end
def start_new_session_for(user)
user.sessions.create!(user_agent: request.user_agent, ip_address: request.remote_ip).tap do |session|
@@ -64,6 +79,10 @@ module Authentication
cookies.signed.permanent[:session_token] = { value: session.signed_id, httponly: true, same_site: :lax }
end
def set_current_account
Current.account = Account.first
end
def terminate_session
Current.session.destroy
cookies.delete(:session_token)
@@ -0,0 +1,13 @@
module CurrentRequest
extend ActiveSupport::Concern
included do
before_action do
Current.http_method = request.method
Current.request_id = request.uuid
Current.user_agent = request.user_agent
Current.ip_address = request.ip
Current.referrer = request.referrer
end
end
end
-19
View File
@@ -1,19 +0,0 @@
class FirstRunsController < ApplicationController
allow_unauthenticated_access
before_action :prevent_repeats
def show
end
def create
user = FirstRun.create!(params.expect(user: [ :name, :email_address, :password ]))
start_new_session_for user
redirect_to root_path
end
private
def prevent_repeats
redirect_to root_path if Account.any?
end
end
@@ -0,0 +1,32 @@
class Prompts::CardsController < ApplicationController
MAX_RESULTS = 10
def index
@cards = if filter_param.present?
prepending_exact_matches_by_id(published_cards.mentioning(params[:filter]))
else
@cards = published_cards.latest
end
if stale? etag: @cards
render layout: false
end
end
private
def filter_param
params[:filter]
end
def published_cards
Current.user.accessible_cards.published.limit(MAX_RESULTS)
end
def prepending_exact_matches_by_id(cards)
if card_by_id = Current.user.accessible_cards.find_by_id(params[:filter])
[ card_by_id ] + cards
else
cards
end
end
end
@@ -0,0 +1,11 @@
class Prompts::Collections::UsersController < ApplicationController
include CollectionScoped
def index
@users = @collection.users
if stale? etag: @users
render layout: false
end
end
end
@@ -0,0 +1,27 @@
class Sessions::LaunchpadController < ApplicationController
require_unauthenticated_access
before_action :store_sig, only: :show
before_action :restore_and_clear_sig, only: :update
def show
end
def update
if user = Current.account.signal_account.authenticate(sig: @sig).try(:peer)
start_new_session_for user
redirect_to after_authentication_url
else
render plain: "Authentication failed. This is probably a bug.", status: :unauthorized
end
end
private
def store_sig
cookies[:_fizzy_launchpad_sig] = params.expect(:sig)
end
def restore_and_clear_sig
@sig = cookies.delete :_fizzy_launchpad_sig
end
end
@@ -0,0 +1,30 @@
class Signup::AccountsController < Signup::BaseController
before_action :reset_signup_storage
rate_limit to: 10, within: 3.minutes, only: :create, with: -> { redirect_to new_signup_account_path, alert: "Try again later." }
def new
@signup = Signup.new
end
def create
@signup = Signup.new(signup_params)
if @signup.recognized?
store_signup
redirect_to Launchpad.authentication_url(purpose: "signup", login_hint: @signup.email_address, redirect_uri: signup_session_url), allow_other_host: true
elsif @signup.process
redirect_to_account(@signup.account)
else
render :new, status: :unprocessable_entity
end
end
private
def signup_params
params.require(:signup).permit(*Signup::PERMITTED_KEYS)
end
def store_signup
@signup.to_h.each { |key, value| signup_storage[key.to_s] = value }
end
end
+40
View File
@@ -0,0 +1,40 @@
class Signup::BaseController < ApplicationController
require_untenanted_access
before_action :redirect_if_local_auth
http_basic_authenticate_with(
name: Rails.application.credentials.dig(:account_signup_http_basic_auth, :name),
password: Rails.application.credentials.dig(:account_signup_http_basic_auth, :password)
)
attr_reader :authenticated_identity
private
def signup_storage
session[:signup] ||= {}
end
def reset_signup_storage
session.delete(:signup)
end
def authenticated_identity=(identity)
@authenticated_identity = identity
signup_storage["identity_id"] = identity.try(:id)
end
def set_authenticated_identity
if identity_id = signup_storage["identity_id"]
@authenticated_identity = SignalId::Identity.find_by(id: identity_id)
end
end
def redirect_to_account(account)
redirect_to account.signal_account.owner.remote_login_url(proceed_to: root_path),
allow_other_host: true
end
def redirect_if_local_auth
render plain: "Unauthorized", status: :unauthorized if Rails.application.config.x.local_authentication
end
end
@@ -0,0 +1,23 @@
class Signup::CompletionsController < Signup::BaseController
before_action :set_authenticated_identity, only: :create
def new
end
def create
@signup = Signup.new(signup_params)
@signup.signal_identity = authenticated_identity
if @signup.process
reset_signup_storage
redirect_to_account(@signup.account)
else
render plain: "Could not complete signup. This is probably a bug.", status: :unprocessable_entity, layout: false
end
end
private
def signup_params
signup_storage.slice(*Signup::PERMITTED_KEYS.map(&:to_s))
end
end
@@ -0,0 +1,9 @@
class Signup::SessionsController < Signup::BaseController
def create
if self.authenticated_identity = SignalId::Identity.authenticate(params.permit(:sig))
redirect_to new_signup_completion_url
else
render plain: "Authentication failed. This is probably a bug.", status: :unauthorized
end
end
end
+1 -1
View File
@@ -35,7 +35,7 @@ module NotificationsHelper
button_to read_notification_path(notification),
class: "card__notification-unread-indicator btn btn--circle borderless",
title: "Mark as read",
data: { turbo_frame: "_top" } do
data: { action: "form#submit:stop", controller: "form", form_target: "submit", turbo_frame: "_top" } do
concat(icon_tag("remove-med"))
concat(tag.span("Mark as read", class: "for-screen-reader"))
end
+9
View File
@@ -0,0 +1,9 @@
module RichTextHelper
def mentions_prompt(collection)
content_tag "lexical-prompt", "", trigger: "@", src: prompts_collection_users_path(collection), name: "mention"
end
def cards_prompt
content_tag "lexical-prompt", "", trigger: "#", src: prompts_cards_path, name: "card", "insert-editable-text": true, "remote-filtering": true
end
end
+10 -1
View File
@@ -1,5 +1,14 @@
class Account < ApplicationRecord
include Entropic, Joinable
include Entropic, Joinable, SignalAccount
has_many_attached :uploads
def setup_basic_template
user = User.first
Closure::Reason.create_defaults
Collection.create!(name: "Cards", creator: user, all_access: true)
workflow = Workflow.create!(name: "Basic")
Collection.first.update!(workflow: workflow)
end
end
+29
View File
@@ -0,0 +1,29 @@
module Account::SignalAccount
extend ActiveSupport::Concern
included do
unless Rails.application.config.x.local_authentication
# TODO: remove the "optional: true" once we've populated the accounts properly
belongs_to :signal_account, class_name: "SignalId::Account", primary_key: :queenbee_id, foreign_key: :queenbee_id, optional: true
end
end
class_methods do
def create_with_admin_user(queenbee_id:)
new(queenbee_id: queenbee_id).tap do |account|
SignalId::Database.on_master do
account.name = account.signal_account.name
account.save!
User.create!(
name: account.signal_account.owner.name,
email_address: account.signal_account.owner.email_address,
signal_user_id: account.signal_account.owner.id,
role: "admin",
password: SecureRandom.hex(36) # TODO: remove password column?
)
end
end
end
end
end
+12
View File
@@ -19,6 +19,10 @@ module Mentions
private
def scan_mentionees
mentionees_from_plain_text | mentionees_from_rich_text
end
def mentionees_from_plain_text
scan_mentioned_handles.filter_map do |mention|
mentionable_users.find { |user| user.mentionable_handles.include?(mention) }
end
@@ -28,6 +32,14 @@ module Mentions
mentionable_content.scan(/(?<!\w)@(\w+)/).flatten.uniq(&:downcase)
end
def mentionees_from_rich_text
mentionees_from_attachments & mentionable_users
end
def mentionees_from_attachments
rich_text_associations.flat_map { send(it.name)&.body&.attachments&.collect { it.attachable } }.compact
end
def mentionable_users
collection.users
end
+2
View File
@@ -1,5 +1,7 @@
class Current < ActiveSupport::CurrentAttributes
attribute :session
attribute :http_method, :request_id, :user_agent, :ip_address, :referrer
attribute :account
delegate :user, to: :session, allow_nil: true
end
-13
View File
@@ -1,13 +0,0 @@
class FirstRun
def self.create!(user_attributes)
user = User.member.create!(user_attributes)
Account.create!(name: "Fizzy")
Closure::Reason.create_defaults
Collection.create!(name: "Cards", creator: user, all_access: true)
workflow = Workflow.create!(name: "Basic")
Collection.first.update!(workflow: workflow)
user
end
end
+20
View File
@@ -0,0 +1,20 @@
# lifted from bc3 app/models/launchpad.rb
module Launchpad
extend self
delegate :url, to: "SignalId.launchpad"
def login_url(product: false, account: nil, **params)
url product_account_path("/signin", product: product, account: account), params
end
def authentication_url(**params)
url "/authenticate", params.merge(product: :fizzy)
end
def product_account_path(path = nil, product: false, account: nil)
product_path = "/fizzy" if product || account
account_path = "/#{account.signal_account.id}" if account
[ product_path, account_path, path ].compact.join
end
end
+159
View File
@@ -0,0 +1,159 @@
class Signup
include ActiveModel::Model
include ActiveModel::Attributes
include ActiveModel::Validations
PERMITTED_KEYS = %i[ full_name email_address password company_name ]
# Input attribute
attr_accessor :company_name
# Input attributes when not using an existing identity
attr_accessor :full_name, :email_address, :password
# Input attribute when using an existing identity, output when creating new identity
attr_accessor :signal_identity
# Output attributes
attr_reader :queenbee_account, :signal_account, :account, :user
validate :validate_new_identity
def initialize(...)
@full_name = nil
@email_address = nil
@password = nil
@company_name = nil
@signal_identity = nil
@queenbee_account = nil
@account = nil
@user = nil
super
end
def process(destroy_existing_tenant: false)
return false unless valid?
create_signal_identity
create_queenbee_account
create_tenant(destroy_existing_tenant:)
true
rescue => error
destroy_tenant
destroy_queenbee_account
destroy_signal_identity
errors.add(:base, "An error occurred during signup: #{error.message}")
false
end
def recognized?
SignalId::Identity.find_by_email_address(email_address).present?
end
def tenant_name
@tenant_name ||= signal_account.subdomain
end
def to_h
{
full_name: full_name,
email_address: email_address,
company_name: company_name
}.compact_blank
end
private
def create_signal_identity
unless signal_identity.present?
SignalId::Database.on_master do
@signal_identity = build_new_identity.tap(&:save!)
end
end
end
def create_queenbee_account
@queenbee_account = Queenbee::Remote::Account.create!(queenbee_account_attributes)
SignalId::Database.on_master do
@signal_account = SignalId::Account.find_by(queenbee_id: @queenbee_account.id)
end
end
def create_tenant(destroy_existing_tenant: false, &block)
ApplicationRecord.destroy_tenant(tenant_name) if destroy_existing_tenant
ApplicationRecord.create_tenant(tenant_name) do
@account = Account.create_with_admin_user(queenbee_id: queenbee_account.id)
@account.setup_basic_template
@user = User.first
end
end
def destroy_tenant
if queenbee_account && ApplicationRecord.tenant_exist?(tenant_name)
ApplicationRecord.destroy_tenant(tenant_name)
@account = nil
@user = nil
end
end
def destroy_queenbee_account
queenbee_account&.cancel
@queenbee_account = nil
end
def destroy_signal_identity
SignalId::Database.on_master do
signal_identity&.destroy
end
@signal_identity = nil
end
def validate_new_identity
unless signal_identity.present?
build_new_identity.tap do |identity|
unless identity.valid?
identity.errors.each { |error| errors.add(error.attribute, error.message) }
end
end
end
end
def build_new_identity
SignalId::Identity.new do |identity|
identity.name = full_name || email_address
identity.email_address = email_address
identity.username = email_address
identity.password = password
end
end
def queenbee_account_attributes
{
skip_remote: true, # Fizzy creates its own local account
product_name: "fizzy",
name: queenbee_account_name,
owner_identity_id: signal_identity.id,
trial: false,
subscription: subscription_attributes,
remote_request: request_attributes
}
end
def subscription_attributes
subscription = FreeV1Subscription
{ name: subscription.to_param, price: subscription.price }
end
def request_attributes
{ remote_address: Current.ip_address, user_agent: Current.user_agent, referrer: Current.referrer }
end
def queenbee_account_name
name = company_name.presence || signal_identity.name
name += " (Beta)" if Rails.env.beta?
name
end
end
+9
View File
@@ -0,0 +1,9 @@
class Subscription < Queenbee::Subscription
SHORT_NAMES = %w[ FreeV1 ]
end
class FreeV1Subscription < Subscription
property :proper_name, "Free Subscription"
property :price, 0
property :frequency, "yearly"
end
+1 -1
View File
@@ -1,5 +1,5 @@
class User < ApplicationRecord
include Accessor, Assignee, Mentionable, Named, Role, Transferable
include Accessor, ActionText::Attachable, Assignee, Mentionable, Named, Role, SignalUser, Transferable
include Timelined # Depends on Accessor
has_one_attached :avatar
+9
View File
@@ -3,6 +3,11 @@ module User::Mentionable
included do
has_many :mentions, dependent: :destroy, inverse_of: :mentionee
# Need to set in the included block so that it overrides Action Text's
def to_attachable_partial_path
"users/attachable"
end
end
def mentioned_by(mentioner, at:)
@@ -13,6 +18,10 @@ module User::Mentionable
[ initials, first_name, first_name_with_last_name_initial ].collect(&:downcase)
end
def content_type
"application/vnd.actiontext.mention"
end
private
def first_name_with_last_name_initial
"#{first_name}#{last_name&.first}"
+9
View File
@@ -0,0 +1,9 @@
module User::SignalUser
extend ActiveSupport::Concern
included do
unless Rails.application.config.x.local_authentication
belongs_to :signal_user, dependent: :destroy, class_name: "SignalId::User", optional: true
end
end
end
+1 -1
View File
@@ -12,7 +12,7 @@
type: "search", autocorrect: "off", autocomplete: "off", data: { "1p-ignore": "true", filter_target: "input", action: "input->filter#filter" } %>
<% end %>
<ul class="flex flex-column full-width popup__list margin-none unpad max-width" data-filter-target="list">
<ul class="popup__list" data-filter-target="list">
<% @collection.users.active.alphabetically.each do |user| %>
<li class="popup__group flex align-center full-width" data-value="<%= user.name.downcase %>" data-filter-target="item" data-navigable-list-target="item">
<%= button_to card_assignments_path(@card, params: { assignee_id: user.id }), method: :post,
+6 -3
View File
@@ -7,10 +7,13 @@
<div class="comment__body rich-text-content txt-align-start">
<%= form_with model: Comment.new, url: card_comments_path(card), class: "flex flex-column gap full-width",
data: { controller: "form local-save",
local_save_key_value: "comment-#{card.id}",
action: "turbo:submit-end->local-save#submit keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" } do |form| %>
local_save_key_value: "comment-#{card.id}",
action: "turbo:submit-end->local-save#submit keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" } do |form| %>
<%= form.rich_textarea :body, required: true, placeholder: new_comment_placeholder(card),
data: { local_save_target: "input", action: "actiontext:change->local-save#save turbo:morph-element->local-save#restoreContent" } %>
data: { local_save_target: "input", action: "actiontext:change->local-save#save turbo:morph-element->local-save#restoreContent" } do %>
<%= mentions_prompt(@card.collection) %>
<%= cards_prompt %>
<% end %>
<%= form.button class: "comment__submit btn btn--reversed flex-item-justify-start" do %>
<span>Post this comment</span>
<% end %>
+5 -2
View File
@@ -8,7 +8,10 @@
<div class="comment__body rich-text-content txt-align-start">
<%= form_with model: [ @card, @comment ], class: "flex flex-column gap full-width",
data: { controller: "form", action: "keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" } do |form| %>
<%= form.rich_textarea :body, required: true, autofocus: true, placeholder: new_comment_placeholder(@card) %>
<%= form.rich_textarea :body, required: true, autofocus: true, placeholder: new_comment_placeholder(@card) do %>
<%= mentions_prompt(@card.collection) %>
<%= cards_prompt %>
<% end %>
<div class="flex gap-half justify-start">
<%= form.button class: "btn btn--reversed", type: :submit do %>
<span>Save changes</span>
@@ -18,7 +21,7 @@
<% end %>
<%= tag.button type: :submit, class: "btn btn--negative flex-item-justify-end", form: dom_id(@comment, :delete_form),
data: { turbo_confirm: "Are you sure you want to delete this comment?" } do %>
data: { turbo_confirm: "Are you sure you want to delete this comment?" } do %>
<%= icon_tag "trash" %>
<span class="for-screen-reader">Delete</span>
<% end %>
+4 -1
View File
@@ -20,7 +20,10 @@
<div class="card__description rich-text-content margin-block-start-half margin-block-end">
<%= form.rich_textarea :description, class: "card-field__description",
placeholder: "Add some notes, context, pictures, or video about this…",
data: { action: "actiontext:change->auto-save#change focusout->auto-save#submit keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" } %>
data: { action: "actiontext:change->auto-save#change focusout->auto-save#submit keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" } do %>
<%= mentions_prompt(card.collection) %>
<%= cards_prompt %>
<% end %>
</div>
<% end %>
<% end %>
+1 -1
View File
@@ -7,7 +7,7 @@
<%= render "cards/display/preview/collection", card: card %>
<%= render "cards/display/preview/tags", card: card %>
<% if card.staged? %>
<% if card.staged? && card.doing? %>
<span class="btn justify-start workflow-stage workflow-stage--current txt-uppercase min-width max-width">
<span class="overflow-ellipsis "><%= card.stage.name %></span>
</span>
@@ -19,7 +19,7 @@
type: "search", autocorrect: "off", autocomplete: "off", data: { "1p-ignore": "true", filter_target: "input", action: "input->filter#filter" } %>
<% end %>
<ul class="flex flex-column full-width popup__list margin-block-start-half margin-none-inline margin-none-block-end unpad max-width" data-filter-target="list">
<ul class="popup__list margin-block-start-half margin-none-block-end" data-filter-target="list">
<% Current.user.collections.alphabetically.each do |collection| %>
<li class="popup__group flex align-center full-width" data-value="<%= collection.name.downcase %>" data-filter-target="item" data-navigable-list-target="item">
<%= button_to card_collection_path(card, params: { collection_id: collection.id }), method: :patch,
+4 -1
View File
@@ -10,7 +10,10 @@
<%= form.rich_textarea :description, class: "card-field__description rich-text-content",
placeholder: "Add some notes, context, pictures, or video about this…",
data: { action: "keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" } %>
data: { action: "keydown.ctrl+enter->form#submit:prevent keydown.meta+enter->form#submit:prevent keydown.esc->form#cancel:stop" } do %>
<%= mentions_prompt(@card.collection) %>
<%= cards_prompt %>
<% end %>
</div>
<%= form.button "Save changes", type: :submit, class: "btn btn--reversed", style: "--btn-background: #{@card.color}" %>
<%= link_to "Close editor and discard changes", collection_card_path(@card.collection, @card), data: { form_target: "cancel" }, hidden: true %>
@@ -7,7 +7,7 @@
aria-label="Closed with reason…" aria-description="Closed with reason…"
data-dialog-target="dialog" data-action="turbo:before-cache@document->dialog#close">
<strong class="popup__title margin-block-start-half pad-inline-half">Closed with reason…</strong>
<%= form_with url: cards_path, method: :get, class: "flex flex-column full-width popup__list",
<%= form_with url: cards_path, method: :get, class: "popup__list",
data: { controller: "form" } do |form| %>
<% filter.as_params.except(:closure_reason_ids).each do |key, value| %>
<%= filter_hidden_field_tag key, value %>
@@ -1,4 +1,4 @@
<%= form_with url: cards_path, method: :get, class: "flex flex-column max-width popup__list full-width",
<%= form_with url: cards_path, method: :get, class: "popup__list",
data: { controller: "form" } do |form| %>
<% filter.as_params.except(:collection_ids).each do |key, value| %>
<%= filter_hidden_field_tag key, value %>
+10 -10
View File
@@ -29,24 +29,24 @@
type: "search", autocorrect: "off", autocomplete: "off", data: { "1p-ignore": "true", filter_target: "input", action: "input->filter#filter" } %>
<%= render "events/filter/header" %>
<% if Current.user.collections.many? %>
<strong class="txt-uppercase txt-x-small margin-block-half pad-inline">Collections</strong>
<menu contents>
<strong class="popup__group-title">Collections</strong>
<ul class="popup__list">
<%= render "cards/index/collections_filter", filter: filter %>
</menu>
</ul>
<% end %>
<strong class="txt-uppercase txt-x-small margin-block-half pad-inline">Tags</strong>
<div class="flex flex-column max-width popup__list full-width">
<strong class="popup__group-title">Tags</strong>
<ul class="popup__list">
<%= render partial: "events/filter/tag", collection: Tag.all.alphabetically, as: :tag %>
</div>
</ul>
<strong class="txt-uppercase txt-x-small margin-block-half pad-inline">People</strong>
<div class="flex flex-column max-width popup__list full-width">
<strong class="popup__group-title">People</strong>
<ul class="popup__list">
<%= render partial: "events/filter/user", collection: User.active.alphabetically, as: :user %>
</div>
</ul>
<% if platform.desktop? %>
<footer class="popup__footer txt-small txt-align-center full-width">
<footer class="popup__footer">
Press <kbd class="kbd"><%= hotkey_label(["ctrl", "J"]) -%></kbd> anytime to open this, <kbd class="kbd">esc</kbd> to close, <kbd class="kbd">↑</kbd><kbd class="kbd">↓</kbd> to move, <kbd class="kbd">enter</kbd> to navigate, <kbd class="kbd">SHIFT+ENTER</kbd> to select.
</footer>
<% end %>
@@ -11,7 +11,7 @@
aria-label="In stage…" aria-description="In stage…"
data-dialog-target="dialog" data-action="turbo:before-cache@document->dialog#close">
<strong class="popup__title margin-block-start-half pad-inline-half">In stage(s)…</strong>
<%= form_with url: cards_path, method: :get, class: "flex flex-column full-width popup__list",
<%= form_with url: cards_path, method: :get, class: "popup__list",
data: { controller: "form" } do |form| %>
<% filter.as_params.except(:stage_ids).each do |key, value| %>
<%= filter_hidden_field_tag key, value %>
+2 -2
View File
@@ -1,5 +1,5 @@
<strong class="txt-uppercase txt-x-small margin-block-half pad-inline">Collections</strong>
<%= form_with url: cards_path, method: :get, class: "flex flex-column max-width popup__list full-width",
<strong class="popup__group-title">Collections</strong>
<%= form_with url: cards_path, method: :get, class: "popup__list",
data: { controller: "form" } do |form| %>
<% if Current.user.collections.one? %>
<%= link_to cards_path, class: "popup__group flex align-center", data: { value: Current.user.collections.first.name.downcase, filter_target: "item", navigable_list_target: "item" } do %>
+7 -7
View File
@@ -25,22 +25,22 @@
type: "search", autocorrect: "off", autocomplete: "off", data: { "1p-ignore": "true", filter_target: "input", action: "input->filter#filter" } %>
<%= render "events/filter/header" %>
<% if Current.user.collections.many? %>
<menu contents>
<ul class="popup__list">
<%= render "cards/show/collections" %>
</menu>
</ul>
<% end %>
<strong class="txt-uppercase txt-x-small margin-block-half pad-inline">Tags</strong>
<div class="flex flex-column max-width popup__list full-width">
<strong class="popup__group-title">Tags</strong>
<div class="popup__list">
<%= render partial: "events/filter/tag", collection: Tag.all.alphabetically, as: :tag %>
</div>
<strong class="txt-uppercase txt-x-small margin-block-half pad-inline">People</strong>
<div class="flex flex-column max-width popup__list full-width">
<strong class="popup__group-title">People</strong>
<div class="popup__list">
<%= render partial: "events/filter/user", collection: User.active.alphabetically, as: :user %>
</div>
<% if platform.desktop? %>
<footer class="popup__footer txt-small txt-align-center full-width">
<footer class="popup__footer">
Press <kbd class="kbd"><%= hotkey_label(["ctrl", "J"]) -%></kbd> anytime to open this, <kbd class="kbd">esc</kbd> to close, <kbd class="kbd">↑</kbd><kbd class="kbd">↓</kbd> to move, <kbd class="kbd">enter</kbd> to navigate, <kbd class="kbd">SHIFT+ENTER</kbd> to select.
</footer>
<% end %>
+1 -1
View File
@@ -13,7 +13,7 @@
autocomplete: "off", data: { filter_target: "input", action: "input->filter#filter" } %>
<% end %>
<ul class="flex flex-column full-width popup__list margin-none unpad max-width" data-filter-target="list">
<ul class="popup__list" data-filter-target="list">
<% @tags.each do |tag| %>
<li class="popup__group flex align-center full-width" data-value="<%= tag.hashtag.downcase %>" data-filter-target="item" data-navigable-list-target="item">
<%= button_to card_taggings_path(@card, params: { tag_title: tag.title }), method: :post,
+9 -9
View File
@@ -25,30 +25,30 @@
<%= render "events/filter/header" %>
<strong class="txt-uppercase txt-x-small margin-block-half pad-inline">Collections</strong>
<%= form_with url: events_path, method: :get, class: "flex flex-column max-width popup__list full-width",
<strong class="popup__group-title">Collections</strong>
<%= form_with url: events_path, method: :get, class: "popup__list",
data: { controller: "form" } do |form| %>
<% if Current.user.collections.many? %>
<menu contents>
<ul class="popup__list">
<%= render "events/filter/custom_collections" %>
<%= render "events/filter/all_collections_option", form: form, filter: filter %>
<%= render partial: "events/filter/collection_option", collection: Current.user.collections.alphabetically, as: :collection, locals: { form: form, filter: filter } %>
</menu>
</ul>
<% end %>
<% end %>
<strong class="txt-uppercase txt-x-small margin-block-half pad-inline">Tags</strong>
<div class="flex flex-column max-width popup__list full-width">
<strong class="popup__group-title">Tags</strong>
<div class="popup__list">
<%= render partial: "events/filter/tag", collection: Tag.all.alphabetically, as: :tag %>
</div>
<strong class="txt-uppercase txt-x-small margin-block-half pad-inline">People</strong>
<div class="flex flex-column max-width popup__list full-width">
<strong class="popup__group-title">People</strong>
<div class="popup__list">
<%= render partial: "events/filter/user", collection: User.active.alphabetically, as: :user %>
</div>
<% if platform.desktop? %>
<footer class="popup__footer txt-small txt-align-center full-width">
<footer class="popup__footer">
Press <kbd class="kbd"><%= hotkey_label(["ctrl", "J"]) -%></kbd> anytime to open this, <kbd class="kbd">esc</kbd> to close, <kbd class="kbd">↑</kbd><kbd class="kbd">↓</kbd> to move, <kbd class="kbd">enter</kbd> to navigate, <kbd class="kbd">SHIFT+ENTER</kbd> to select.
</footer>
<% end %>
@@ -1,4 +1,4 @@
<div class="flex flex-column max-width popup__list full-width">
<div class="popup__list">
<% @filters.each do |filter| %>
<div class="popup__group flex align-center full-width gap-half overflow-ellipsis" data-filter-target="item" data-navigable-list-target="item">
<%= link_to cards_path(**filter.as_params), class: "btn popup__new popup__item" do %>
+1 -1
View File
@@ -1,4 +1,4 @@
<div class="flex flex-wrap align-center justify-center full-width max-width btn__group--chunky">
<div class="flex flex-wrap align-center justify-center full-width max-width fizzy-menu__hotkeys">
<%= link_to root_path, class: "btn popup__group borderless", data: { value: "home", filter_target: "item", navigable_list_target: "item", controller: "hotkey", action: "keydown.1@document->hotkey#click keydown.shift+1@document->hotkey#click" } do %>
<%= icon_tag "home" %>
<span>Home</span>
+1 -1
View File
@@ -14,7 +14,7 @@
<dialog class="events__popup popup panel flex-column align-start gap-half fill-white shadow" data-dialog-target="dialog" data-action="turbo:before-cache@document->dialog#close">
<strong class="popup__title margin-block-start-half pad-inline-half overflow-ellipsis">Assigned to…</strong>
<%= form_with url: cards_path, method: :get, class: "flex flex-column full-width popup__list",
<%= form_with url: cards_path, method: :get, class: "popup__list",
data: { controller: "form" } do |form| %>
<% filter.as_params.except(:assignee_ids, :assignment_status).each do |key, value| %>
<%= filter_hidden_field_tag key, value %>
+1 -1
View File
@@ -15,7 +15,7 @@
aria-label="Added by…" aria-description="Added by…"
data-dialog-target="dialog" data-action="turbo:before-cache@document->dialog#close">
<strong class="popup__title margin-block-start-half pad-inline-half">Added by…</strong>
<%= form_with url: cards_path, method: :get, class: "flex flex-column full-width popup__list",
<%= form_with url: cards_path, method: :get, class: "popup__list",
data: { controller: "form" } do |form| %>
<% filter.as_params.except(:creator_ids).each do |key, value| %>
<%= filter_hidden_field_tag key, value %>
+1 -1
View File
@@ -10,7 +10,7 @@
aria-label="Sort by…" aria-description="Sort by…"
data-dialog-target="dialog" data-action="turbo:before-cache@document->dialog#close">
<strong class="popup__title margin-block-start-half pad-inline-half">Sort by…</strong>
<%= form_with url: cards_path, method: :get, class: "flex flex-column full-width popup__list",
<%= form_with url: cards_path, method: :get, class: "popup__list",
data: { controller: "form" } do |form| %>
<% filter.as_params.except(:indexed_by).each do |key, value| %>
<%= filter_hidden_field_tag key, value %>
+1 -1
View File
@@ -14,7 +14,7 @@
aria-label="In stage…" aria-description="In stage…"
data-dialog-target="dialog" data-action="turbo:before-cache@document->dialog#close">
<strong class="popup__title margin-block-start-half pad-inline-half overflow-ellipsis">In stage…</strong>
<%= form_with url: cards_path, method: :get, class: "flex flex-column full-width popup__list",
<%= form_with url: cards_path, method: :get, class: "popup__list",
data: { controller: "form" } do |form| %>
<% filter.as_params.except(:stage_ids).each do |key, value| %>
<%= filter_hidden_field_tag key, value %>
+1 -1
View File
@@ -12,7 +12,7 @@
<dialog class="events__popup popup panel flex-column align-start gap-half fill-white shadow" data-dialog-target="dialog" data-action="turbo:before-cache@document->dialog#close">
<strong class="popup__title margin-block-start-half pad-inline-half overflow-ellipsis">Tagged…</strong>
<%= form_with url: cards_path, method: :get, class: "flex flex-column full-width popup__list",
<%= form_with url: cards_path, method: :get, class: "popup__list",
data: { controller: "form" } do |form| %>
<% filter.as_params.except(:tag_ids).each do |key, value| %>
<%= filter_hidden_field_tag key, value %>
-32
View File
@@ -1,32 +0,0 @@
<% @page_title = "Set up Fizzy" %>
<div class="panel shadow center margin-block-double <%= "shake" if flash[:alert] %>" style="--panel-size: 55ch;">
<h1 class="txt-xx-large margin-block-end-double">Fizzy</h1>
<%= form_with model: User.new, url: first_run_path, class: "flex flex-column gap txt-large" do |form| %>
<div class="flex align-center gap">
<label class="flex align-center gap input input--actor txt-large">
<%= form.text_field :name, class: "input", autocomplete: "name", placeholder: "Name", autofocus: true, required: true, data: { "1p-ignore": true } %>
<%= icon_tag "person", class: "txt-large" %>
</label>
</div>
<div class="flex align-center gap">
<label class="flex align-center gap input input--actor txt-large">
<%= form.email_field :email_address, class: "input", autocomplete: "username", placeholder: "Email address", required: true %>
<%= icon_tag "email", class: "txt-large" %>
</label>
</div>
<div class="flex align-center gap">
<label class="flex align-center gap input input--actor txt-large">
<%= form.password_field :password, class: "input", autocomplete: "new-password", placeholder: "Password", required: true, maxlength: 72 %>
<%= icon_tag "password", class: "txt-large" %>
</label>
</div>
<button type="submit" id="log_in" class="btn btn--reversed center">
<%= icon_tag "arrow-right" %>
<span class="for-screen-reader">Create your account</span>
</button>
<% end %>
</div>
+8
View File
@@ -0,0 +1,8 @@
<lexical-prompt-item>
<template type="menu">
#<%= card.id %> <%= card.title %>
</template>
<template type="editor">
<%= link_to "##{card.id} #{card.title}", collection_card_url(card.collection, card) %>
</template>
</lexical-prompt-item>
+1
View File
@@ -0,0 +1 @@
<%= render partial: "prompts/cards/card", collection: @cards %>
@@ -0,0 +1,10 @@
<lexical-prompt-item search="<%= "#{user.name} #{user.initials}" %>" sgid="<%= user.attachable_sgid %>">
<template type="menu">
<%= avatar_image_tag user %>
<%= user.name %>
</template>
<template type="editor">
<%= avatar_image_tag user %>
<%= user.first_name %>
</template>
</lexical-prompt-item>
@@ -0,0 +1 @@
<%= render partial: "prompts/collections/users/user", collection: @users %>
@@ -0,0 +1 @@
<%= auto_submit_form_with method: :put %>
+58
View File
@@ -0,0 +1,58 @@
<% @page_title = "Sign up for Fizzy" %>
<div class="panel shadow center margin-block-double <%= "shake" if flash[:alert] %>" style="--panel-size: 65ch;">
<h1 class="txt-xx-large margin-block-end-double">Fizzy</h1>
<h2 class="txt-large margin-block-end-double">Create your account</h2>
<%= form_with model: @signup, url: signup_accounts_path, scope: "signup", class: "flex flex-column gap txt-large", data: { turbo: false } do |form| %>
<div class="flex align-center gap">
<label class="flex align-center gap input input--actor txt-large">
<%= form.text_field :full_name, class: "input", autocomplete: "name", placeholder: "Your name", autofocus: true, required: true %>
<%= icon_tag "person", class: "txt-large" %>
</label>
</div>
<div class="flex align-center gap">
<label class="flex align-center gap input input--actor txt-large">
<%= form.email_field :email_address, class: "input", autocomplete: "username", placeholder: "Your email address", required: true %>
<%= icon_tag "email", class: "txt-large" %>
</label>
</div>
<div class="flex align-center gap">
<label class="flex align-center gap input input--actor txt-large">
<%= form.text_field :company_name, class: "input", autocomplete: "organization", placeholder: "Your organization's name", required: true %>
<%= icon_tag "building", class: "txt-large" %>
</label>
</div>
<div class="flex align-center gap">
<label class="flex align-center gap input input--actor txt-large">
<%= form.password_field :password, class: "input", autocomplete: "new-password", placeholder: "Password (at least 12 characters)", required: true, maxlength: 72, minlength: 12 %>
<%= icon_tag "password", class: "txt-large" %>
</label>
</div>
<% if @signup.errors.any? %>
<div class="alert alert--error">
<ul class="margin-block-none">
<% @signup.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<button type="submit" class="btn btn--reversed center">
<%= icon_tag "arrow-right" %>
<span>Create your account</span>
</button>
<% end %>
<footer class="margin-block-start-double txt-center">
<p class="txt-small txt-muted">
Already have an account?
<%= link_to "Sign in", Launchpad.login_url(product: true), class: "decorated" %>
</p>
</footer>
</div>
@@ -0,0 +1 @@
<%= auto_submit_form_with url: signup_completions_path, data: { turbo: false } %>
+2
View File
@@ -0,0 +1,2 @@
<%= avatar_image_tag user %>
<%= user.first_name %>
-2
View File
@@ -2,10 +2,8 @@
echo "Access with david@37signals.com / secret123456 on http://37signals.fizzy.localhost:3006"
echo "Access with david@37signals.com / secret123456 on http://honcho.fizzy.localhost:3006"
echo "Access first run on http://first-run.fizzy.localhost:3006"
if [ -f tmp/solid-queue.txt ]; then
# see also config/environments/development.rb
export SOLID_QUEUE_IN_PUMA=1
fi
+7 -2
View File
@@ -7,8 +7,13 @@ system("gem install bundler --conservative")
system("bundle check") || system!("bundle install")
puts "\n== Preparing database =="
system! "bin/rails db:prepare"
system! "bin/rails db:reset" if ARGV.include?("--reset")
if ARGV.include?("--reset")
system! "bin/rails db:drop db:create db:schema:load db:prepare"
system! "bin/rails db:fixtures:load" # load signal id fixtures before running fizzy seeds
system! "bin/rails db:seed"
else
system! "bin/rails db:prepare"
end
puts "\n== Removing old logs and tempfiles =="
system! "bin/rails log:clear tmp:clear"
+1
View File
@@ -10,6 +10,7 @@ CI.run do
step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error"
step "Tests: Rails", "bin/rails test"
step "Tests: 37id", "bin/rails 37id:test:units"
if success?
step "Signoff: All systems go. Ready for merge and deploy.", "gh signoff"
+1 -1
View File
@@ -1 +1 @@
/M5cK3B/Wq1R2f4XsQQ8t7qJGCNMaBU3itDDKNZapeGMjrlE4A9CKOh2RPQsKlrsGjJdgGL4YdfDiT1LiF0pD/KL/t5Fw8Z8GMndzA+hmpvQ+hgufvd8n2ZzK/SIK6RmfC9BabdlvuQ0rr3/EdQJ5yIgk3WtWv73bOWDopqHerEAcaNl8KjCu5n2vokAidqEh0/O30yQQB9PAFMRqEwapLVcDSnaJXr6OQnAAm9tIoWaEaIF8ZJH3Rmn0COs8xZqYFyFv/pP3gVy3Mw+VL3aIkEf/eZ3IQWYNf7fiKPvIdNVsmghmeoQRqCp4E0dpn2XDSEGx5i5oq7ynSDCPky4GahiLtpe3vas+Z+lzD5hAAZmlX0G615b4hWjAViTEQU+CMXhmpj7eVpT9co+1oKmqtbDvHr7uCJIf4d17kC13u/8yjgl2SaCjRWx94wh8su/h6h9Xw0qewITbfM32pFRiH+R5bc=--fxg4Lf+qmYeuvyZp--qVZ+ELV0FRzBb69MUAqOsQ==
yD+Wb/DYMXUXf16brcYmNb7OZngx7PuA6gLP8w6ZV7pA/GFweTIXQw67YoXebL6A7gWxhXX4tvpnrz9xPC1p5+5a8q00lOhcybc/480bSfenIXExDpg6mSDbQLRomrpwZsr+IqBWRC8WoXTtpVYRybEPRP88ZXdyE0MjnfOlGcr1fUcF3r9T8biXfNhS9v9UTxck1Q2yzGoNFRvBm3KgBN2QlJocG5AXaDYUSMfLoLmtkicO0KVOD7eYBxe0kDevUK7ZQGxbtATGIsjAJEifhuaPTj/Q2DH134TU4rlPr6n6vm9YEgC/CGVoqPIDgvovVd5IiqZlxGuVDDw3ct0cPH4/AnDLOVHiubi5/pZRCQyeOF+G1OmLVFUVeuZ6alPVrJSxHFxGVM/HT+pLWiReR/IJYacLzb3b2OOtBp/AKxbt25YxFFHuFHABfVEqPRo0qVwbQaxKZEyDyEW0t0ktTkqHywqkbmsx3HPU3skV42rTHf9c1udJhzTXZJPKIWKP96ahgX6eaOhy09rzwD99RnKLJuAuMl80Cw1L+ftLG5hM4tHYmv+JbpWDSInvP1jtA5iWtDRSiqhTxvXw4ItHximvsERI5k6hSPJzrzqnhLUxNVi10KQHh5KGq7EiC2J3HJwioY+fWVivzEJdw2WuVFsovcFyolrT3tyqcMVF5V3dQw4/gOGQ7gXsz0a9sb5wQgXJ9pyZBAmD4Bg7r4wD0f0JiL/5EdKOkAOTV2JDt0OgoSWzU+yuvByuWxPn2quoX0ZSG0huHvl2NK33RoR9aXLcSjtmi5XJGXpjATfKMoA2d8JBIDiJcOPAPwWkpI7AOnmvlV2SmrqPlLTkO0MfdJ528k8CFi2puSRQN+CEPhyhZclDO/J2IP94qmE35xAmWM7yjY6E+EZY9uhlF39uBhAIXylZwTyF31PSCqwpQJthH62pDFWpCgjy1iax482cCVt5n2HLQpmSNaPAvsI7hZAGFJsrDncwfJiK68YXrgmTeg8=--Gd2i+KrusH11oais--SraJ0tcCfEgIdRSJYoCoNw==
+1 -1
View File
@@ -1 +1 @@
e6HcyVNJvgdJGTCcvW8FDHQ9pHYmTgNW2/71Espdwxd5LQX/Xb9+VkeqpEAL8ceFJo6ro49mMs85G2YngKbbXEh//8+XSS0inMR/kh9wTV3f6YBWnLVBbhrOnYgPcQ8vedsgxLG2Cs4y7ipfeT/dp9bb5m4IxV0OPiUjVFFJuAuGkMJt3pE7D6alqL1ACRxazV9UKV5WKEBfaQYeKpJ++5f86Nls1EwKo5tB1lJed9P5n2yH7meX/QyxjgQd38CIaPwp+F92y5DLKJD5MZRMJGgWf6MT6096LhjEC3vvtS3wwT/eDVcVKTplRkEszj7BZEfOs9Ondb9EVtbXmeoHYSgpaOq9/nEwtho5Uw==--KlHZy1apg3u1XxFs--N2ehRobLHNz+Sepc8p4EIA==
hHi9VfqfZI57GYR5r75Rh00L3FYeoUgsdtCgBzJGdPhhGZLGlCgUAcY6tgVhqhgM/u5WxyPAzmoV+bbQKb0wO0U6KUmrDKDv9uRnNSUjXGYsg8gWdTUTfx7WBFOFawV3p1Dsm7zM1oUJbRWgy2TiMFl5kP9CN4Lk78n5a1/Y7mnx/wmzwUyQv+BUFJ0UKCfRmWieaNoLkm5kWwyepWHaSRU5EVda6J9VtuwP3hISIzncXm0jpr9r/tJm8flgUR3o2STIVHVgg+YcWz0j3st5RMv7i3D2OHfNGx84vsf3LNlsiLGB1ZU0IsmchLOSqPmlDK2oiB7UKepTPthGUFzNmcDG4LpUQfNwn7B8P7by0zadVefdLSB3kFCNLX+KCAbcjHPlWufkagwHo+dV+iIVACC+hfWl3L/IouqYS0ln2XCBDz6A8lI8PHeGaJe9UhfA5JEuJLRPui8znoq7LrKo4/oBB7ArKdHz/W/oHJA9+aLHcLOq9TPSExAsGi1wcjdgF0nk5mB9iX+az3//1zM64rRz8EyzNkBg1I6PV6wpzROLubttv1+I5nRVe4BernNfVJPi/hbvrlrcZmouU32oOzd2aZuPL5/H8gBhMOPlN25Rr51XCziaczgdEfOwTO8da6r7pxsJDkrvKi+Ju6efxhZ6GeotWJuEUtQtWhHnzvhcJAXfUtJf6A==--LWtZauLkWq+tD+4L--mkMcqYebO9TDi6RTRNUQvA==
+1 -1
View File
@@ -1 +1 @@
Qkkt3krcGlOh76//7TmY5rXz31mhL3NNNiO6TCcRhx4lT6uZ8sLjW7X0yp2wZ30y2cJBeIsNMeOhL47JTpu0Z4d0FPtwgH4bzzXAiab0/YrfMjxyBt7/K1DzW/bePUvakJggIvVGGRA1NB1tPkwziQ4dgA34E/UUPAxUofFaoCIFVABmqt4DFg0DKYB8tImDDjZ5xUfSNxrKj3pH5mOIU9jqSK6LtBELFZcNxrJOI0UEgayRNwYAs44ujdM2/2zrAWlUCFZ3NnMU4bBJW02/d6LaxHtB809u8mx3bChEzWMtZB9CvpbRH7u6XfZJR1YGFztcNguzRbNX5x9pKZImAuAhVFKR7Fbi12Hg3w7U3A/YovwmyhHK6ksfOzYlYoDUTBnGYVwiqR16naaHCMekomK+uo0qtOn83RQkLuuqFJshPXn/lW8Rggt4dq/0THEl7Qj+dumadlWDVEgxhipI031RSBF1toNWw/zwaC31Ord1YXD8ZC09sPoj8Zd4hD2o2a0IUuIMXA8w5/2+AmdU8wF66opdKGdsZDRjM2Bul8aAbJ7/iV7xTuvlE4LmzXGArMvivIZ0GwrN3YAsDta2Y3ufibq7WZzSCOrpbPKJ9CEGg4a2AN8UGaGZ5ZEAYg8lhDgsZMjHRkGNuxk8QkDiVFDyNoI7H9ZQqbw+L1AJRTfDIcNB/9h5n+//Nk4ylLF1lnI7y/IQA/AuSQ+UN+okt3RhLugwGvWrs49MExJStLHpGveSoQ==--MQ2JycR3X6LIiKSY--45C7bleKX9FMGv4fc+G1yw==
cPaSSbJ2XWIjNXDRi25iIH41V4zR1fKVTe3OV1XwZKsDE3950DL9Sk0RI/1bxYY+agT3x88vrsqm8xyqLwqcMeMg7J4el52WI4hORe5XLGJTcvs8CTgguut1s7c5Jb5ihOSPX9ZYEKnONxLU2BFe951WZgO+7Us2IBFbCQiqo8PXEBEONePuE9Aw2z1fUFW7NvxhptN9ws7DptP2O/wpHP8+KJmVuAJl+/qJHJoBh770dwQU3yCFz9mERowkARK4Z16Q+HelkrkvW9UziV53tMIExDRvfMnEKJz4wrU/AqVSiQIMQA6eVfTMQLxbVhBhLAlcV0Qic7yEbXLcAXwLX6Kga6aMOqyJvxh+JUnMpQavtHKikVncN8EI1lGxd/+mddS+khHZmCqkRvWl+LiaOe2WOkNENYXlGYANeRSTDy2BqMeT1ymAj3YxfWKYdR34Ukmm2iZKQ1d6fF+Dg/IT8/W/rIZPajQjMTO05WqeoE8kZ4lhgI7AxZHg9apHGsfkq6iBaf4kZUBFiLgOv0CR4Xz3zWVUHEyPx5afkkxs1u9NCspOaYWT4gA1tekFmaH7cMyhaQ/SEjOEs5AWyh47PeMBvwUumJKIEeil6p6K9109nSFG+BPszfhshY8G3aonuIbiWoJQAiGGnbvw4oZeAPjl8XNBYTWRspl2T9ep3krPg9L4eTrAyXdT0dG7GYXSa5NkrV86HugU5rDfx87N4QJVu6qr7Myzw6kEWlLRXHR6HPg+H7qZenNwuOfgnD3YoFOEWQfLiz9U3MqDRJIq5j+i2OfL4pv0VVpc51z42mWM1hvazGNiiTZvK0DohBja6D9h5Gl6ZP/eOGB8O6ZxIwn6mp23BIvxA5jBRGslCj/Op7msnkSAP0vtuI0hnCtfsOx0RfAPvj1/ETjVkRFhM7heWTgfqGQbjSVGvd/Dj6j3vbqGz3RASVL48juoWx/pezUMnWGZqRTqx5tD6FrZQy1yf/2Uoc8yMJQU14raqiShEqNveJswMv1D9kBnT3551DtpAmrMT+iuCX5aZHwVnY/sI8J8HdMAqFfpipMbCbccI/PgTc+U8uOqc+BBKYy8u04wHLnGu5wREewU5xRIkxcwGm4M4LkTLiVEJFUQuFEhylmxkCIDYG+WSBZ5xy/0dRUeZs26Uuvvs2//bXqn75Ia86Fpv54AzfdDqC9s0B1r5/ZqXtFjjL3bQyhv+SP5TMurAZcI0rLYay+oFcLZAv31zoo0dFcufiCKYTjbYEIlO+HW2aeVeOhUnCalqqslt4ql5A==--tVMd1KheRgH8F0gZ--x8Wzmk8JsMuHPG3JMOODLA==
+1 -1
View File
@@ -1 +1 @@
Tc514fYVlm1c2c81KUFmuAGZ9rSZT7XfeGUEdTEvJNDD6JE9IghKbAAGgc6AalIt2/VxzSV6AGvNagb0/qlJrMOFeSJLqzufKt1YoLRUZtSUZDtzmI/2WSzju0Tyu4za6rnLEu+WvDc0NM8KZEv/TwwUbSOwoEVDQmava/iuWyvFr5sOH1wNr2kwYbErwDpThKcYqacI4CdvgpgXc//3T0Z5vsVCs6d8723DymM0KkSK9so8WrcCesk2fmhM1K2hQV7QdckaVuOL0JEj1AI7KzdJm7REoeLwRwaBeVDkqDNo2fKxwZ2XmU2ViJOozPsowx+324wQQZjbsf/0ohtDkgjbfMmA2tjkuNJdJFwIIc5VyvILhVs5WQIJHSXmabhUKxzKRCRZNBSaadLGCb7/AGfULKvh2vP1BWqm80EkDmhHm42ucJW1vA377y9UYWVcdvqWtp5m2F7HlXqi9Uw/+kFV2X6Jw1c07FsCu35TrUb5u8G7mu/866GBgf5VlfDVs1BA3aru05CtcsXi2qKMw0II7fZps/4XpYCB+VehXAyLi2ABVeTnqSfF7HwasXvfD3Jgh7zJLD0hudNDpu6xkmoyeksVBn034dmQf0TwAIKmNjEKMDxgAG50T2JhnEvHZEnqzGl6O8if8DKM5rPgt3XEU3zN/U3yA8T0eTyFdGlWbYK7Ergu4TcGo3WPk6bJhTGOHQ9puD89t6JGHbKDYW77w44SHv+C42flmNkzcndXlpvZvnEafDYeW6ofRC4eeTRX4+WKL4CYhlel78+twD6Mh0MV42MYM2rmtl66NHN8fWxyfoyjrNcXveKVAsGMatB0+dOglq6GmOiXRAt/I6D+7QYZLyaUhYNmmiuZXF4=--H6KWPnHc9qUQE5AI--PN98cg9t9ZhxMUEax/Mfgg==
UV9AzQwwjGoWFh78GyKkNewL4lmsL80XT+6ca0JIxZ632OMKbYzHbPtJAD6RK1gaEB4GRkiBE2mLEdk8nYI47p8K9NVqZuI/ykgl23Sr2JzBpnHfjtUYlPQC7nYyeAR3gYtASD5+jSXvP7z/9BquIVdTvih3hWPPujDK5OROXagDtlGTZ4SgKZwihCTZczTwZRLxx5UguwbtuowrtcuPTg+r/H+zdJyRUU9kF7vYqV5VrP0vj0mXZUf5hWNBpKvhDtqgKUJNU87ds7xTLwMuQT0MCASoJXS0JSpIKn653baXczQue6JyuFcOlqDPDmBWs2GLh6pq3XnNolmWoyNehD9WClmJ5cu0l1dPmYZ0WnOfPgJCPtuJI4onIUj2nLC5m0tHpQPNs9WjazQeCeiw7TJBESK3b3lB4/Q7STshfJd74IIJ0crzfm0a6MwxvT184etKpNRQkgJpwK6WRNPzzzHdlTmbkEher4ilcs3QrmyLPYaLcuG+0YyDcjgJXRvLumkZilONbuBHluMc64u4GAU8QyW1dp31DrCw/HTOTtPEsRJSKdTIiFFCDpYt0Uq16Qbp9Hsg1ruPqFPELXDqRSl+N9ixDdXjF8WyQc6Gx4i8Fsur0WBLGBEtCEkCCQvwsH+TVH47S68CSiUui0c2zX1sM2SEcOOL7Cn0TUDWg6d8MgHQ5977aGLGFUkeuldv8KfQSra5uHKESSxOwn/eJJQMERSiVHSEj1EK3v4R+shXnviPsFTr3uERjdn2s1CcSk3qJ7J5WFpmgTL3/qKRWDroI96QO24CSr8JWJ3MzrwkLLpiEf6qLyfswiRjJ79sxk1pTdx7zedb8MuG/596J/xFI7GdbOI6qr2NvVUHH6r6PqXltMBoT/JU4pO7QOXfuok8zYGOj2gMzktCOdvEoydh+8QrsKN6W3mpg8oweQoq/2tgRm/YTeJMz+MQMR+J+aid2lSu8/wiHtsv8WdxJ7qxI7G3DslzbTEz0e3FAP+CEFOVgwN+nitWZpSyst6WiupzU5Ou9NxYiWbmhy5nhDJFC+MiLs8bS2kWfq3z1/Wsuyp8hBn/T0+MYtQ/cRnAxTE0sUZMB5xpffuGPlx3lgrmxV7wlPAzGooCGkGVUQQ29ljYMoGWqW34EAniIYHLUnBfdS+bNbui5rxVEAFyD4r57eqEaYYOKCFwkhNfYhHUeV+noDQ8uvCzppvRHGKJrkUCS0/A6z4KP8iW8wsETMBX/ShvFf6m44KNGR4IPzfn+r7uO9Pbukit3RJ8RsHZcC3RmH7/gtO3vW8G1BBrT5nI6BdRp9a9LdTx2qn32cKQD7Wc7S1KyFYn5reWSewlxNHHmgy4R/pEXTtdZJ5tuzweeYK/jr78CTrgEEN/btNawNh6SdTwRls5lu33LAQ=--3mgI5AzJFOeaM6Y1--KgIowEbtW/iHXiDrWGk2ug==
+1 -1
View File
@@ -1 +1 @@
1QS4QGl05L2YvkEusirttYv2gIfRbNLEWTK7pOXG/HxqyWbWj9o5/GbTFeLI7K0sbcEjz+gcPcVKB+FGnkJECExynSvIN02bg5gP0QegTC3WA2vWInlr+gpoWCNgsazOgHKMxDBnQXc2myqhwDLKRsDHCQTW/5lIiKGBtBOLaml8KrJg84SeYCh54tJUjAp5yRkrhJQBHVgSSUfXRaFcrmdbPls5FDgB+rErACpp4BgYdXZoDds2RITDgQ==--kpoxQbOswL3cInhK--r+dB3hJHvjfUH1EBNzBLSg==
0EWG0dpq/qELmTCqAgzEL2BZH7JCNFe6DDIynyKE06qiBCYhHs5+fMjmpzN0uwz43tqAAKJdhO/hZY4jXmTpxu+LSjwrUKAlxsWvSGu4vHEbJ5Fe+BzhnS84OFse6ma9akUAHaed3gt31wtrt2Y4AbSPedNvriqfYln7cvpgWbvjTgtEbHrcNMfjCBnaXXdO3n1zILunILgLiV7oBW0qurIrl2xOc4mbj4AqVAIElyh0md9fFtuBfsaRj2U9x/+7McbgTFODhHfeqoErEw7fc1RBFrNi2YNuHBSSqegQ6WtWbwq1Yg2Juv/Lw3g4IIdWadJsW3TdTMMiwmBMzOikx3lNX1Z399glvGNFVnJfBubKf6BxvFfAQ85PBzOZPl+/PTs=--GY44JMs5ZO1/eL6G--EhxEWuitiex6eHROpPQamw==
+1
View File
@@ -13,6 +13,7 @@ proxy:
env:
clear:
RAILS_ENV: beta
LOCAL_AUTHENTICATION: t
x-beamer-accessory: &beamer-accessory
image: basecamp/beamer
+6
View File
@@ -0,0 +1,6 @@
Queenbee.host_app = Fizzy
Rails.application.config.to_prepare do
Queenbee::Subscription.short_names = Subscription::SHORT_NAMES
Queenbee::ApiToken.token = Rails.application.credentials.dig(:queenbee_api_token)
end
+27
View File
@@ -0,0 +1,27 @@
require "signal_id"
Rails.application.config.x.local_authentication = ENV["LOCAL_AUTHENTICATION"].present?
ENV["SIGNAL_ID_SECRET"] = Rails.application.credentials.signal_id_secret
Rails.application.config.to_prepare do
SignalId.product = "fizzy"
db_config = SignalId::Database.default_configuration
if Rails.application.config.x.local_authentication
db_config.each do |name, config|
config["connect_timeout"] = 1
end
end
SignalId::Database.load_configuration db_config
SignalId::Database.enable_rw_splitting!
silence_warnings do
SignalId::Account::Peer = Account
SignalId::User::Peer = User
end
end
Rails.application.config.after_initialize do
ActiveRecord.yaml_column_permitted_classes << SignalId::PersonName
end
+10 -3
View File
@@ -2,10 +2,17 @@ Rails.application.config.after_initialize do
# in production and staging, we're using a two-level subdomain like "tenant.fizzy.37signals.com".
# in development and beta it's only a single-level subdomain.
Rails.application.config.active_record_tenanted.tenant_resolver = ->(request) do
next nil if request.path == "/up"
tld_length = [ "37signals.com", "37signals-staging.com" ].include?(request.domain) ? 2 : 1
request.subdomain(tld_length).presence || "return-404" # this is a quick fix for now
if request.path == "/up"
nil
elsif subdomain = request.subdomain(tld_length).presence
subdomain
elsif request.path =~ %r{^/(queenbee|signup)\b}
nil
else
"return-404" # this is a quick fix for now
end
end
end
+19 -2
View File
@@ -1,6 +1,4 @@
Rails.application.routes.draw do
resource :first_run
resource :account do
resource :join_code, module: :accounts
@@ -87,6 +85,14 @@ Rails.application.routes.draw do
scope module: "sessions" do
resources :transfers, only: %i[ show update ]
end
resource :launchpad, only: %i[ show update ], controller: "sessions/launchpad"
end
namespace :signup do
get "/" => "accounts#new"
resources :accounts, only: %i[ new create ]
get "/session" => "sessions#create" # redirect from Launchpad after mid-signup authentication
resources :completions, only: %i[ new create ]
end
resources :users do
@@ -105,6 +111,15 @@ Rails.application.routes.draw do
resources :pins
end
namespace :prompts do
resources :cards
resources :collections do
scope module: :collections do
resources :users
end
end
end
namespace :public do
resources :collections do
scope module: :collections do
@@ -143,6 +158,8 @@ Rails.application.routes.draw do
root "events#index"
Queenbee.routes(self)
namespace :admin do
mount MissionControl::Jobs::Engine, at: "/jobs"
end
@@ -0,0 +1,6 @@
class AddQueenbeeIdToAccounts < ActiveRecord::Migration[8.1]
def change
add_column :accounts, :queenbee_id, :integer # I solemnly swear I will add a "NOT NULL" constraint once it's populated
add_index :accounts, :queenbee_id, unique: true
end
end
@@ -0,0 +1,6 @@
class AddSignalUserIdToUsers < ActiveRecord::Migration[8.1]
def change
add_column :users, :signal_user_id, :integer
add_index :users, :signal_user_id, unique: true
end
end
Generated
+6 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2025_06_09_102553) do
ActiveRecord::Schema[8.1].define(version: 2025_06_12_163028) do
create_table "accesses", force: :cascade do |t|
t.integer "collection_id", null: false
t.datetime "created_at", null: false
@@ -26,7 +26,9 @@ ActiveRecord::Schema[8.1].define(version: 2025_06_09_102553) do
t.datetime "created_at", null: false
t.string "join_code"
t.string "name", null: false
t.integer "queenbee_id"
t.datetime "updated_at", null: false
t.index ["queenbee_id"], name: "index_accounts_on_queenbee_id", unique: true
end
create_table "action_text_rich_texts", force: :cascade do |t|
@@ -324,6 +326,7 @@ ActiveRecord::Schema[8.1].define(version: 2025_06_09_102553) do
t.datetime "created_at", null: false
t.string "title"
t.datetime "updated_at", null: false
t.index ["title"], name: "index_tags_on_account_id_and_title", unique: true
end
create_table "users", force: :cascade do |t|
@@ -333,9 +336,11 @@ ActiveRecord::Schema[8.1].define(version: 2025_06_09_102553) do
t.string "name", null: false
t.string "password_digest"
t.string "role", default: "member", null: false
t.integer "signal_user_id"
t.datetime "updated_at", null: false
t.index ["email_address"], name: "index_users_on_email_address", unique: true
t.index ["role"], name: "index_users_on_role"
t.index ["signal_user_id"], name: "index_users_on_signal_user_id", unique: true
end
create_table "watches", force: :cascade do |t|
+71 -3
View File
@@ -113,6 +113,16 @@ columns:
default_function:
collation:
comment:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: queenbee_id
cast_type: *1
sql_type_metadata: *2
'null': true
default:
default_function:
collation:
comment:
- *9
action_text_rich_texts:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
@@ -983,6 +993,16 @@ columns:
default_function:
collation:
comment:
- !ruby/object:ActiveRecord::ConnectionAdapters::SQLite3::Column
auto_increment:
name: signal_user_id
cast_type: *1
sql_type_metadata: *2
'null': true
default:
default_function:
collation:
comment:
- *9
watches:
- *22
@@ -1163,7 +1183,23 @@ indexes:
nulls_not_distinct:
comment:
valid: true
accounts: []
accounts:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: accounts
name: index_accounts_on_queenbee_id
unique: true
columns:
- queenbee_id
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
action_text_rich_texts:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: action_text_rich_texts
@@ -2200,7 +2236,23 @@ indexes:
nulls_not_distinct:
comment:
valid: true
tags: []
tags:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: tags
name: index_tags_on_account_id_and_title
unique: true
columns:
- title
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
users:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: users
@@ -2234,6 +2286,22 @@ indexes:
nulls_not_distinct:
comment:
valid: true
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: users
name: index_users_on_signal_user_id
unique: true
columns:
- signal_user_id
lengths: {}
orders: {}
opclasses: {}
where:
type:
using:
include:
nulls_not_distinct:
comment:
valid: true
watches:
- !ruby/object:ActiveRecord::ConnectionAdapters::IndexDefinition
table: watches
@@ -2285,4 +2353,4 @@ indexes:
comment:
valid: true
workflows: []
version: 20250609102553
version: 20250612163028
+38 -11
View File
@@ -7,24 +7,52 @@ def seed_account(name)
puts " #{elapsed.round(2)} sec"
end
def create_tenant(name)
ApplicationRecord.destroy_tenant name
ApplicationRecord.create_tenant name
ApplicationRecord.current_tenant = name
def create_tenant(signal_account_name)
signal_account = SignalId::Account.find_by_product_and_name!("fizzy", signal_account_name)
tenant_name = signal_account.subdomain
ApplicationRecord.destroy_tenant tenant_name
ApplicationRecord.create_tenant(tenant_name) do
account = Account.create_with_admin_user(queenbee_id: signal_account.queenbee_id)
account.setup_basic_template
end
ApplicationRecord.current_tenant = tenant_name
end
def create_first_run(name, email_address, password: "secret123456")
FirstRun.create!(name:, email_address:, password:)
def find_or_create_user(full_name, email_address)
SignalId::Database.on_master do
unless signal_identity = SignalId::Identity.find_by_email_address(email_address)
signal_identity = SignalId::Identity.create!(
name: full_name,
email_address: email_address,
username: email_address,
password: "secret123456"
)
end
signal_account = Account.first.signal_account
signal_user = SignalId::User.find_or_create_by!(identity: signal_identity, account: signal_account)
if user = User.find_by(signal_user_id: signal_user.id)
user.password = "secret123456"
user.save!
user
else
User.create!(
signal_user_id: signal_user.id,
name: signal_identity.name,
email_address: signal_identity.email_address,
password: "secret123456"
)
end
end
end
def login_as(user)
Current.session = user.sessions.create
end
def create_user(name, email_address, password: "secret123456")
User.create!(name:, email_address:, password:)
end
def create_collection(name, creator: Current.user, all_access: true, access_to: [])
Collection.create!(name:, creator:, all_access:).tap { it.accesses.grant_to(access_to) }
end
@@ -36,4 +64,3 @@ end
# Seed accounts
seed_account "37signals"
seed_account "honcho"
seed_account "first-run"
+6 -5
View File
@@ -1,12 +1,13 @@
create_tenant "37signals"
david = create_first_run "David Heinemeier Hansson", "david@37signals.com"
david = find_or_create_user "David Heinemeier Hansson", "david@37signals.com"
jason = find_or_create_user "Jason Fried", "jason@37signals.com"
jz = find_or_create_user "Jason Zimdars", "jz@37signals.com"
kevin = find_or_create_user "Kevin Mcconnell", "kevin@37signals.com"
login_as david
jz = create_user "Jason Zimdars", "jz@37signals.com"
kevin = create_user "Kevin Mcconnell", "kevin@37signals.com"
create_collection("Fizzy", access_to: [ jz, kevin ]).tap do |fizzy|
create_collection("Fizzy", access_to: [ jason, jz, kevin ]).tap do |fizzy|
create_card("Prepare sign-up page", description: "We need to do this before the launch.", collection: fizzy)
create_card("Prepare sign-up page", description: "We need to do this before the launch.", collection: fizzy).tap do |card|
-1
View File
@@ -1 +0,0 @@
create_tenant "first-run"
+9 -8
View File
@@ -1,15 +1,16 @@
create_tenant "honcho"
create_tenant "Honcho"
david = find_or_create_user "David Heinemeier Hansson", "david@37signals.com"
jason = find_or_create_user "Jason Fried", "jason@37signals.com"
jz = find_or_create_user "Jason Zimdars", "jz@37signals.com"
kevin = find_or_create_user "Kevin McConnell", "kevin@37signals.com"
jorge = find_or_create_user "Jorge Manrubia", "jorge@37signals.com"
mike = find_or_create_user "Mike Dalessio", "mike@37signals.com"
david = create_first_run "David Heinemeier Hansson", "david@37signals.com"
login_as david
jason = create_user "Jason Zimdars", "jz@37signals.com"
kevin = create_user "Kevin Mcconnell", "kevin@37signals.com"
sarah = create_user "Sarah Johnson", "sarah@37signals.com"
mike = create_user "Mike Peterson", "mike@37signals.com"
# Array of authors for random assignment
authors = [ david, jason, kevin, sarah, mike ]
authors = [ david, jason, jz, kevin, jorge, mike ]
# Card titles for reuse across collections
card_titles = [
+114
View File
@@ -0,0 +1,114 @@
<!doctype html>
<html lang="en">
<head>
<title>The server cannot process the request due to a client error (400 Bad Request)</title>
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1, width=device-width">
<meta name="robots" content="noindex, nofollow">
<style>
*, *::before, *::after {
box-sizing: border-box;
}
* {
margin: 0;
}
html {
font-size: 16px;
}
body {
background: #FFF;
color: #261B23;
display: grid;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, Aptos, Roboto, "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
font-size: clamp(1rem, 2.5vw, 2rem);
-webkit-font-smoothing: antialiased;
font-style: normal;
font-weight: 400;
letter-spacing: -0.0025em;
line-height: 1.4;
min-height: 100vh;
place-items: center;
text-rendering: optimizeLegibility;
-webkit-text-size-adjust: 100%;
}
a {
color: inherit;
font-weight: 700;
text-decoration: underline;
text-underline-offset: 0.0925em;
}
b, strong {
font-weight: 700;
}
i, em {
font-style: italic;
}
main {
display: grid;
gap: 1em;
padding: 2em;
place-items: center;
text-align: center;
}
main header {
width: min(100%, 12em);
}
main header svg {
height: auto;
max-width: 100%;
width: 100%;
}
main article {
width: min(100%, 30em);
}
main article p {
font-size: 75%;
}
main article br {
display: none;
@media(min-width: 48em) {
display: inline;
}
}
</style>
</head>
<body>
<!-- This file lives in public/400.html -->
<main>
<header>
<svg height="172" viewBox="0 0 480 172" width="480" xmlns="http://www.w3.org/2000/svg"><path d="m124.48 3.00509-45.6889 100.02991h26.2239v-28.1168h38.119v28.1168h21.628v35.145h-21.628v30.82h-37.308v-30.82h-72.1833v-31.901l50.2851-103.27391zm115.583 168.69891c-40.822 0-64.884-35.146-64.884-85.7015 0-50.5554 24.062-85.700907 64.884-85.700907 40.823 0 64.884 35.145507 64.884 85.700907 0 50.5555-24.061 85.7015-64.884 85.7015zm0-133.2831c-17.572 0-22.709 21.8984-22.709 47.5816 0 25.6835 5.137 47.5815 22.709 47.5815 17.303 0 22.71-21.898 22.71-47.5815 0-25.6832-5.407-47.5816-22.71-47.5816zm140.456 133.2831c-40.823 0-64.884-35.146-64.884-85.7015 0-50.5554 24.061-85.700907 64.884-85.700907 40.822 0 64.884 35.145507 64.884 85.700907 0 50.5555-24.062 85.7015-64.884 85.7015zm0-133.2831c-17.573 0-22.71 21.8984-22.71 47.5816 0 25.6835 5.137 47.5815 22.71 47.5815 17.302 0 22.709-21.898 22.709-47.5815 0-25.6832-5.407-47.5816-22.709-47.5816z" fill="#f0eff0"/><path d="m123.606 85.4445c3.212 1.0523 5.538 4.2089 5.538 8.0301 0 6.1472-4.209 9.5254-11.298 9.5254h-15.617v-34.0033h14.565c7.089 0 11.353 3.1566 11.353 9.2484 0 3.6551-2.049 6.3134-4.541 7.1994zm-12.904-2.9905h5.095c2.603 0 3.988-.9968 3.988-3.1013 0-2.1044-1.385-3.0459-3.988-3.0459h-5.095zm0 6.6456v6.5902h5.981c2.492 0 3.877-1.3291 3.877-3.2674 0-2.049-1.385-3.3228-3.877-3.3228zm43.786 13.9004h-8.362v-1.274c-.831.831-3.323 1.717-5.981 1.717-4.929 0-9.083-2.769-9.083-8.0301 0-4.818 4.154-7.9193 9.581-7.9193 2.049 0 4.486.6646 5.483 1.3845v-1.606c0-1.606-.942-2.9905-3.046-2.9905-1.606 0-2.548.7199-2.935 1.8275h-8.197c.72-4.8181 4.985-8.6393 11.409-8.6393 7.088 0 11.131 3.7659 11.131 10.2453zm-8.362-6.9779v-1.4399c-.554-1.0522-2.049-1.7167-3.655-1.7167-1.717 0-3.434.7199-3.434 2.3813 0 1.7168 1.717 2.4367 3.434 2.4367 1.606 0 3.101-.6645 3.655-1.6614zm27.996 6.9779v-1.994c-1.163 1.329-3.599 2.548-6.147 2.548-7.199 0-11.131-5.8151-11.131-13.0145s3.932-13.0143 11.131-13.0143c2.548 0 4.984 1.2184 6.147 2.5475v-13.0697h8.695v35.997zm0-9.1931v-6.5902c-.664-1.3291-2.159-2.326-3.821-2.326-2.99 0-4.763 2.4368-4.763 5.6488s1.773 5.5934 4.763 5.5934c1.717 0 3.157-.9415 3.821-2.326zm35.471-2.049h-3.101v11.2421h-8.806v-34.0033h15.285c7.31 0 12.35 4.1535 12.35 11.5744 0 5.1503-2.603 8.6947-6.757 10.2453l7.975 12.1836h-9.858zm-3.101-15.2849v8.1962h5.538c3.156 0 4.596-1.606 4.596-4.0981s-1.44-4.0981-4.596-4.0981zm36.957 17.8323h8.03c-.886 5.7597-5.206 9.2487-11.685 9.2487-7.643 0-12.682-5.2613-12.682-13.0145 0-7.6978 5.316-13.0143 12.515-13.0143 7.643 0 11.962 5.095 11.962 12.5159v2.1598h-16.115c.277 2.9905 1.827 4.5965 4.32 4.5965 1.772 0 3.156-.7753 3.655-2.4921zm-3.822-10.0237c-2.049 0-3.433 1.2737-3.987 3.5997h7.532c-.111-2.0491-1.385-3.5997-3.545-3.5997zm30.98 27.5234v-10.799c-1.163 1.329-3.6 2.548-6.147 2.548-7.2 0-11.132-5.9259-11.132-13.0145 0-7.144 3.932-13.0143 11.132-13.0143 2.547 0 4.984 1.2184 6.147 2.5475v-1.9937h8.695v33.726zm0-17.9981v-6.5902c-.665-1.3291-2.105-2.326-3.821-2.326-2.991 0-4.763 2.4368-4.763 5.6488s1.772 5.5934 4.763 5.5934c1.661 0 3.156-.9415 3.821-2.326zm36.789-15.7279v24.921h-8.695v-2.16c-1.329 1.551-3.821 2.714-6.646 2.714-5.482 0-8.75-3.5999-8.75-9.1379v-16.3371h8.64v14.288c0 2.1045.996 3.5997 3.212 3.5997 1.606 0 3.101-1.0522 3.544-2.769v-15.1187zm19.084 16.2263h8.03c-.886 5.7597-5.206 9.2487-11.685 9.2487-7.643 0-12.682-5.2613-12.682-13.0145 0-7.6978 5.316-13.0143 12.515-13.0143 7.643 0 11.963 5.095 11.963 12.5159v2.1598h-16.116c.277 2.9905 1.828 4.5965 4.32 4.5965 1.772 0 3.156-.7753 3.655-2.4921zm-3.822-10.0237c-2.049 0-3.433 1.2737-3.987 3.5997h7.532c-.111-2.0491-1.385-3.5997-3.545-3.5997zm13.428 11.0206h8.474c.387 1.3845 1.606 2.1598 3.156 2.1598 1.44 0 2.548-.5538 2.548-1.7168 0-.9414-.72-1.2737-1.939-1.5506l-4.873-.9969c-4.154-.886-6.867-2.8797-6.867-7.2547 0-5.3165 4.762-8.4178 10.633-8.4178 6.812 0 10.522 3.1567 11.297 8.0855h-8.03c-.277-1.0522-1.052-1.9937-3.046-1.9937-1.273 0-2.326.5538-2.326 1.6614 0 .7753.554 1.163 1.717 1.3845l4.929 1.163c4.541 1.0522 6.978 3.4335 6.978 7.4763 0 5.3168-4.818 8.2518-10.91 8.2518-6.369 0-10.965-2.88-11.741-8.2518zm27.538-.8861v-9.5807h-3.655v-6.7564h3.655v-6.8671h8.584v6.8671h5.205v6.7564h-5.205v8.307c0 1.9383.941 2.769 2.658 2.769.941 0 1.993-.2216 2.769-.5538v7.3654c-.997.443-2.88.775-4.818.775-5.871 0-9.193-2.769-9.193-9.0819z" fill="#d30001"/></svg>
</header>
<article>
<p><strong>The server cannot process the request due to a client error.</strong> Please check the request and try again. If youre the application owner check the logs for more information.</p>
</article>
</main>
</body>
</html>
-8
View File
@@ -112,14 +112,6 @@
<article>
<p><strong>The page you were looking for doesnt exist.</strong> You may have mistyped the address or the page may have moved. If youre the application owner check the logs for more information.</p>
</article>
<article>
<p>You may want to try one of these subdomains:</p>
<ul>
<li><a href="https://37s.fizzy.37signals.com/">37s.fizzy.37signals.com</a></li>
<li><a href="https://dev.fizzy.37signals.com/">dev.fizzy.37signals.com</a></li>
<li><a href="https://qa.fizzy.37signals.com/">qa.fizzy.37signals.com</a></li>
</ul>
</article>
</main>
</body>
+108 -61
View File
File diff suppressed because one or more lines are too long
+108 -60
View File
File diff suppressed because one or more lines are too long
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env ruby
require_relative "../config/environment"
ActiveRecord::Base.logger = Logger.new(File::NULL)
class BootstrapSignalId
def initialize(dry_run: false)
@dry_run = dry_run
end
def run
SignalId::Database.on_master do
ApplicationRecord.with_each_tenant do |tenant|
puts "\n# tenant: #{tenant}"
next unless check_account_preconditions
create_signal_id_account if Account.first.queenbee_id.nil?
create_signal_id_users
end
end
end
def check_account_preconditions
unless Account.count == 1
puts "There are #{Account.count} accounts, but exactly one is expected."
false
else
true
end
end
def create_signal_id_account
owner = SignalId::Identity.find_by_email_address!("kevin@37signals.com")
print_identity("New owner is:", owner)
unless @dry_run
qbattr = queenbee_account_attributes(owner)
queenbee_account = Queenbee::Remote::Account.create!(qbattr)
signal_id_account = SignalId::Account.find_by!(queenbee_id: queenbee_account.id)
signal_id_account.update_column :subdomain, ApplicationRecord.current_tenant
account = Account.first
account.queenbee_id = queenbee_account.id
account.name = ApplicationRecord.current_tenant
account.save!
end
end
def create_signal_id_users
signal_account = Account.first.signal_account
User.find_each do |user|
if !user.system? && user.signal_user_id.nil?
signal_identities = SignalId::Identity.where(email_address: user.email_address)
if signal_identities.length > 1
puts "Multiple identities found for #{user.email_address}:"
signal_identities.each { |identity| print_identity(" - ", identity) }
signal_identity = signal_identities.first
elsif signal_identities.length == 1
signal_identity = signal_identities.first
print_identity("Identity for #{user.email_address}:", signal_identity)
else
puts "No identity found for #{user.name} (#{user.email_address})"
signal_identity = nil
end
if signal_identity
unless @dry_run
signal_user = SignalId::User.find_or_create_by!(identity: signal_identity, account: signal_account)
user.signal_user_id = signal_user.id
user.save!
end
end
end
end
end
def print_identity(message = "Identity:", identity)
pad = " " * message.length
puts "#{message} #{identity.name} (#{identity.email_address})"
puts "#{pad } ID: #{identity.id}"
puts "#{pad } Username: #{identity.username}"
end
def queenbee_account_attributes(signal_identity)
{
skip_remote: true, # Fizzy creates its own local account
product_name: "fizzy",
name: account_name,
owner_identity_id: signal_identity.id,
trial: false,
subscription: subscription_attributes,
remote_request: request_attributes
}
end
def subscription_attributes
subscription = FreeV1Subscription
{ name: subscription.to_param, price: subscription.price }
end
def request_attributes
{ user_agent: "script/bootstrap-signal-id.rb" }
end
def account_name
name = ApplicationRecord.current_tenant
name += " (Beta)" if Rails.env.beta?
name
end
end
BootstrapSignalId.new(dry_run: false).run
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env ruby
require_relative "../config/environment"
CHANGES = [
{ from: "david@37signals.com", to: "david@hey.com" },
{ from: "jay@37signals.com", to: "jay@basecamp.com" },
{ from: "jeremy@37signals.com", to: "jeremy@basecamp.com" },
{ from: "jillian@37signals.com", to: "jillian@basecamp.com" },
{ from: "jorge@37signals.com", to: "jorge@basecamp.com" },
{ from: "merissa@37signals.com", to: "merissa@basecamp.com" },
{ from: "michelle@37signals.com", to: "michelle@basecamp.com" },
{ from: "scott@37signals.com", to: "scott@basecamp.com" },
{ from: "silvia@37signals.com", to: "silvia@basecamp.com" }
]
ApplicationRecord.with_each_tenant do |tenant|
CHANGES.each do |change|
user = User.find_by(email_address: change[:from])
if user
puts "Updating user #{user.id} in tenant #{tenant}: #{change[:from]} -> #{change[:to]}"
user.email_address = change[:to]
user.save!
end
end
end
@@ -1,29 +0,0 @@
require "test_helper"
class FirstRunsControllerTest < ActionDispatch::IntegrationTest
setup do
Account.destroy_all
end
test "show" do
get first_run_path
assert_response :ok
assert_select "title", text: "Set up Fizzy"
end
test "show after completion" do
Account.create! name: "Fizzy"
get first_run_path
assert_redirected_to root_url
end
test "create" do
assert_difference -> { Account.count }, +1 do
post first_run_path, params: { user: { name: "New", email_address: "new@37signals.com", password: "secret123456" } }
assert_redirected_to root_url
end
follow_redirect!
assert_response :ok
end
end
@@ -0,0 +1,30 @@
require "test_helper"
class Sessions::LaunchpadControllerTest < ActionDispatch::IntegrationTest
test "show renders when not signed in" do
get session_launchpad_path(params: { sig: "test-sig" })
assert_response :success
assert_equal cookies[:_fizzy_launchpad_sig], "test-sig"
end
test "create establishes a session when the sig is valid" do
user = users(:david)
cookies[:_fizzy_launchpad_sig] = user.signal_user.perishable_signature
put session_launchpad_path
assert_redirected_to root_url
assert parsed_cookies.signed[:session_token]
end
test "returns 401 when the sig is invalid" do
user = users(:david)
cookies[:sig] = "not-valid"
put session_launchpad_path
assert_response :unauthorized
end
end
@@ -0,0 +1,108 @@
require "test_helper"
class Signup::AccountsControllerTest < ActionDispatch::IntegrationTest
test "new under a tenanted domain redirects to the root" do
get new_signup_account_url
assert_redirected_to root_url
end
test "new under an untenanted domain is OK" do
integration_session.host = "example.com" # no subdomain
get new_signup_account_url, headers: auth_headers
assert_response :success
end
test "create with invalid params" do
integration_session.host = "example.com" # no subdomain
post signup_accounts_url,
headers: auth_headers,
params: { signup: { full_name: "Jim", email_address: "jim@example.com", password: "", company_name: "" } }
assert_response :unprocessable_entity
assert_select "div.alert--error", text: /you need to choose a password/i
end
test "create for a new " do
integration_session.host = "example.com" # no subdomain
assert_difference -> { SignalId::Identity.count }, +1 do
assert_difference -> { SignalId::Account.count }, +1 do
post signup_accounts_url, headers: auth_headers,
params: {
signup: {
full_name: "Jim",
email_address: "jim@example.com",
password: SecureRandom.hex(12),
company_name: "signup-accounts-controller-test-1"
}
}
end
end
signal_account = SignalId::Account.last
assert_redirected_to(/#{signal_account.login_url}/)
end
test "create for an existing identity" do
integration_session.host = "example.com" # no subdomain
identity = signal_identities(:david)
post signup_accounts_url, headers: auth_headers,
params: { signup: { email_address: identity.email_address, company_name: "signup-accounts-controller-test-2" } }
assert_authentication_requested_for identity
assert_no_difference -> { SignalId::Identity.count } do
assert_difference -> { SignalId::Account.count } do
authenticate_via_launchpad_as(identity)
assert_redirected_to_account
assert_nil session[:signup]
end
end
signal_account = SignalId::Account.last
ApplicationRecord.with_tenant(signal_account.subdomain) do
assert_equal Account.last, signal_account.peer
end
end
test "actions require HTTP basic authentication while we're in internal-only mode" do
integration_session.host = "example.com" # no subdomain
get new_signup_account_url
assert_response :unauthorized
end
private
def auth_headers
{
"HTTP_AUTHORIZATION" => ActionController::HttpAuthentication::Basic.encode_credentials(
Rails.application.credentials.dig(:account_signup_http_basic_auth, :name),
Rails.application.credentials.dig(:account_signup_http_basic_auth, :password)
)
}
end
def assert_authentication_requested_for(identity)
assert_response :redirect
assert_match(/#{Regexp.escape(Launchpad.url("/authenticate", login_hint: identity.email_address))}.*&purpose=signup/, redirect_to_url)
end
def authenticate_via_launchpad_as(identity)
get signup_session_url, headers: auth_headers, params: { sig: identity.perishable_signature }
assert_equal identity.id, session[:signup]["identity_id"]
assert_redirected_to new_signup_completion_url
post signup_completions_url, headers: auth_headers
end
def assert_redirected_to_account(signal_account = SignalId::Account.last)
assert_response :redirect
assert_match(/#{Regexp.escape(signal_account.url("/session/launchpad"))}.*&sig=/, redirect_to_url)
end
end
+1
View File
@@ -1,3 +1,4 @@
37s:
name: 37signals
join_code: "ejpP-THlQ-Cc2f"
queenbee_id: <%= ActiveRecord::FixtureSet.identify :'37s_fizzy' %>
+3
View File
@@ -5,18 +5,21 @@ david:
email_address: david@37signals.com
password_digest: <%= digest %>
role: member
signal_user: 37s_fizzy_david
jz:
name: JZ
email_address: jz@37signals.com
password_digest: <%= digest %>
role: member
signal_user: 37s_fizzy_jzimdars
kevin:
name: Kevin
email_address: kevin@37signals.com
password_digest: <%= digest %>
role: admin
signal_user: 37s_fizzy_kevin
system:
name: System
@@ -0,0 +1,39 @@
require "test_helper"
class Account::SignalAccountTest < ActiveSupport::TestCase
# # TODO(MIKE): Queenbee client API compliance tests
# include Queenbee::Testing::Client
setup do
@account = accounts("37s")
end
test "belongs to a signal_account via a shared queenbee_id" do
assert_not_nil @account.queenbee_id
assert_equal @account.queenbee_id, Account.new(signal_account: @account.signal_account).queenbee_id
assert_equal @account.signal_account, Account.new(queenbee_id: @account.queenbee_id).signal_account
end
test ".create_with_admin_user creates a new local account and user peers" do
ApplicationRecord.create_tenant("account-create-with-dependents") do
signal_account = signal_accounts(:honcho_fizzy)
account = Account.create_with_admin_user(queenbee_id: signal_account.queenbee_id)
assert_not_nil account
assert account.persisted?
assert_equal 1, Account.count
assert_equal signal_account.queenbee_id, account.queenbee_id
assert_equal signal_account.name, account.name
assert_equal account, signal_account.peer
assert_equal 1, User.count
User.first.tap do |user|
assert signal_account.owner.name, user.name
assert signal_account.owner.email_address, user.email_address
assert signal_account.owner.id, user.signal_user_id
assert_equal "admin", user.role
assert_equal user, signal_account.owner.peer
end
end
end
end

Some files were not shown because too many files have changed in this diff Show More