Compare commits
	
		
			156 Commits
		
	
	
		
			0.1.7
			...
			fdfca3f7a5
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| fdfca3f7a5 | |||
| c61b2c67b7 | |||
| d0d958a638 | |||
| a437b5966f | |||
| e2378279d7 | |||
| 1b49b668b3 | |||
| 03021614b5 | |||
| 50af86798a | |||
| be01723be2 | |||
| 0a27a4ee29 | |||
| e2f8ac6b78 | |||
| d5e334dc09 | |||
| 1d6ba5960c | |||
| bc29ca6c20 | |||
| bf9fd4880f | |||
| 957e433847 | |||
| edd631f520 | |||
| 2e1545a9f5 | |||
| 3e296080f5 | |||
| d2ae6024ce | |||
| 4615a29c11 | |||
| eb48ff7dc0 | |||
| fcfd9857d5 | |||
| c5f96a9d9d | |||
| c1a0b4017f | |||
| 04ebe59afe | |||
| 50be85a1c3 | |||
| 994aa96a20 | |||
| 026bf22f60 | |||
| 56e6eb3609 | |||
| c49140e7f5 | |||
| 1276635a3e | |||
| f00dc50215 | |||
| 35de8a6395 | |||
| 96e155a49a | |||
| c02fb06eb2 | |||
| a9d5649bef | |||
| 650d61e95f | |||
| 63d854ffbe | |||
| a1c846be33 | |||
| 1b9f212e66 | |||
| 7805ddc270 | |||
| c1455bccad | |||
| dd956be93f | |||
| 04361a5838 | |||
| cb049cb178 | |||
| 5a41d8b3e7 | |||
| 64320dbdae | |||
| a03f8ebb8a | |||
| 4e17739b4d | |||
| d495db0bb3 | |||
| bf91eadd8e | |||
| 7d16cec5db | |||
| 50b7f8f9b6 | |||
| a0d3fb09f3 | |||
| 2d0f6eefab | |||
| d33da32b2f | |||
| 468489f872 | |||
| 5dedb4668b | |||
| 0d4deb6805 | |||
| 8f288afeb9 | |||
| aa5a1f30f9 | |||
| daa50039a7 | |||
| 98bb99881d | |||
| 56956f37fb | |||
| a0b93d0f46 | |||
| 066587f839 | |||
| 5cde99de90 | |||
| cc29f875fa | |||
| 5f2e69abbd | |||
| 8ef5147078 | |||
| c5e82c60b6 | |||
| c7debb331c | |||
| bc2c936480 | |||
| 9b4837a044 | |||
| bd5dd57c6c | |||
| 4f0f3ec610 | |||
| 6b7abdb3e1 | |||
| db2ec3b4a3 | |||
| f8a1d88e0e | |||
| 0eafb54266 | |||
| b1d442ea88 | |||
| 8b59339f74 | |||
| 563bbaedf8 | |||
| 12814be5e1 | |||
| bc034c0361 | |||
| eb9280fa7e | |||
| ad1e44fd42 | |||
| 482a3902b5 | |||
| 2fa1105f40 | |||
| df44be2b1b | |||
| 56dae6cdfe | |||
| 8ef3bd65a3 | |||
| 6e635d1b30 | |||
| ebe09bcf84 | |||
| 0ea953a34c | |||
| 1284a0c4b1 | |||
| fd0b2c455a | |||
| f1139d0ec4 | |||
| f10d061279 | |||
| cd6bb6fbc3 | |||
| ad7810a8ea | |||
| 5c05f3b6fe | |||
| cd7220cea3 | |||
| 30d3f76fe1 | |||
| ed8c20e967 | |||
| eb75937587 | |||
| 7b60938a75 | |||
| f19d024d8a | |||
| 1726a1b72e | |||
| 1fbed50b0f | |||
| f990fafc6a | |||
| 737484c36e | |||
| 2cb6aa8d33 | |||
| 24f608163f | |||
| 6dbadc58ae | |||
| f155a43ee8 | |||
| bbaa1dfd6b | |||
| 2c2b9fefc9 | |||
| bafc824a32 | |||
| 8c2f7e0509 | |||
| 22abc7a8d0 | |||
| 5a685ac00e | |||
| a5c12b3e17 | |||
| 27af5acf8b | |||
| 469428c007 | |||
| 1cd28e43b8 | |||
| 695002c9d9 | |||
| dde60d71d1 | |||
| 91794ddc55 | |||
| 1e3cec95fe | |||
| f0a8c515f9 | |||
| e99775eef2 | |||
| 6760f83ca0 | |||
| 10877bb754 | |||
| 38a581b639 | |||
| 9408705430 | |||
| 302aa7eeda | |||
| fd4fdcc36b | |||
| 3cb723b9e4 | |||
| 1f92c452d1 | |||
| c10cff63ea | |||
| 70faed71d0 | |||
| b5c46c09ec | |||
| 7745765fc0 | |||
| e16fbba810 | |||
| e35bdf101b | |||
| aa314e5ca1 | |||
| 616de3c117 | |||
| 74bcec6cfe | |||
| 41090c46d0 | |||
| c3f5744ad6 | |||
| 95a339fe02 | |||
| 1e3b027367 | |||
| dd46e1795f | |||
| 9e517e6477 | 
							
								
								
									
										14
									
								
								.credo.exs
									
									
									
									
									
								
							
							
						
						
									
										14
									
								
								.credo.exs
									
									
									
									
									
								
							| @@ -157,17 +157,17 @@ | ||||
|         # | ||||
|         # Controversial and experimental checks (opt-in, just replace `false` with `[]`) | ||||
|         # | ||||
|         {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, | ||||
|         {Credo.Check.Consistency.UnusedVariableNames, false}, | ||||
|         {Credo.Check.Consistency.MultiAliasImportRequireUse, []}, | ||||
|         {Credo.Check.Consistency.UnusedVariableNames, [force: :meaningful]}, | ||||
|         {Credo.Check.Design.DuplicatedCode, false}, | ||||
|         {Credo.Check.Readability.AliasAs, false}, | ||||
|         {Credo.Check.Readability.BlockPipe, false}, | ||||
|         {Credo.Check.Readability.ImplTrue, false}, | ||||
|         {Credo.Check.Readability.MultiAlias, false}, | ||||
|         {Credo.Check.Readability.SeparateAliasRequire, false}, | ||||
|         {Credo.Check.Readability.SeparateAliasRequire, []}, | ||||
|         {Credo.Check.Readability.SinglePipe, false}, | ||||
|         {Credo.Check.Readability.Specs, false}, | ||||
|         {Credo.Check.Readability.StrictModuleLayout, false}, | ||||
|         {Credo.Check.Readability.StrictModuleLayout, []}, | ||||
|         {Credo.Check.Readability.WithCustomTaggedTuple, false}, | ||||
|         {Credo.Check.Refactor.ABCSize, false}, | ||||
|         {Credo.Check.Refactor.AppendSingleItem, false}, | ||||
| @@ -176,9 +176,9 @@ | ||||
|         {Credo.Check.Refactor.NegatedIsNil, false}, | ||||
|         {Credo.Check.Refactor.PipeChainStart, false}, | ||||
|         {Credo.Check.Refactor.VariableRebinding, false}, | ||||
|         {Credo.Check.Warning.LeakyEnvironment, false}, | ||||
|         {Credo.Check.Warning.MapGetUnsafePass, false}, | ||||
|         {Credo.Check.Warning.UnsafeToAtom, false} | ||||
|         {Credo.Check.Warning.LeakyEnvironment, []}, | ||||
|         {Credo.Check.Warning.MapGetUnsafePass, []}, | ||||
|         {Credo.Check.Warning.UnsafeToAtom, []} | ||||
|  | ||||
|         # | ||||
|         # Custom checks can be created using `mix credo.gen.check`. | ||||
|   | ||||
							
								
								
									
										27
									
								
								.drone.yml
									
									
									
									
									
								
							
							
						
						
									
										27
									
								
								.drone.yml
									
									
									
									
									
								
							| @@ -13,20 +13,24 @@ steps: | ||||
|     mount: | ||||
|       - _build | ||||
|       - deps | ||||
|       - assets/node_modules/ | ||||
|       - .npm | ||||
|       - .mix | ||||
|  | ||||
| - name: test | ||||
|   image: elixir:1.14.1-alpine | ||||
|   image: elixir:1.15.6-alpine | ||||
|   environment: | ||||
|     TEST_DATABASE_URL: ecto://postgres:postgres@database/memex_test | ||||
|     HOST: testing.example.tld | ||||
|     MIX_HOME: /drone/src/.mix | ||||
|     MIX_ARCHIVES: /drone/src/.mix/archives | ||||
|     MIX_ENV: test | ||||
|   commands: | ||||
|   - apk add --no-cache build-base npm git python3 | ||||
|   - mix local.rebar --force | ||||
|   - mix local.hex --force | ||||
|   - apk add --no-cache build-base npm git | ||||
|   - mix local.rebar --force --if-missing | ||||
|   - mix local.hex --force --if-missing | ||||
|   - mix deps.get | ||||
|   - mix deps.compile | ||||
|   - npm --prefix ./assets ci --progress=false --no-audit --loglevel=error | ||||
|   - npm set cache .npm | ||||
|   - npm --prefix ./assets ci --no-audit --prefer-offline | ||||
|   - npm run --prefix ./assets deploy | ||||
|   - mix do phx.digest, gettext.extract | ||||
|   - mix test.all | ||||
| @@ -38,7 +42,8 @@ steps: | ||||
|     repo: shibaobun/memex | ||||
|     purge: true | ||||
|     compress: true | ||||
|     platforms: linux/amd64,linux/arm/v7 | ||||
|     platforms: | ||||
|       - linux/amd64 | ||||
|     username: | ||||
|       from_secret: docker_username | ||||
|     password: | ||||
| @@ -55,7 +60,8 @@ steps: | ||||
|     repo: shibaobun/memex | ||||
|     purge: true | ||||
|     compress: true | ||||
|     platforms: linux/amd64,linux/arm/v7 | ||||
|     platforms: | ||||
|       - linux/amd64 | ||||
|     username: | ||||
|       from_secret: docker_username | ||||
|     password: | ||||
| @@ -76,7 +82,8 @@ steps: | ||||
|     mount: | ||||
|       - _build | ||||
|       - deps | ||||
|       - assets/node_modules/ | ||||
|       - .npm | ||||
|       - .mix | ||||
|  | ||||
| services: | ||||
| - name: database | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| [ | ||||
|   import_deps: [:ecto, :phoenix], | ||||
|   inputs: ["*.{heex,ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{heex,ex,exs}"], | ||||
|   import_deps: [:ecto, :ecto_sql, :phoenix], | ||||
|   inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"], | ||||
|   subdirectories: ["priv/*/migrations"], | ||||
|   plugins: [Phoenix.LiveView.HTMLFormatter] | ||||
| ] | ||||
|   | ||||
| @@ -1,3 +1,3 @@ | ||||
| elixir 1.14.1-otp-25 | ||||
| erlang 25.1.2 | ||||
| nodejs 16.18.0 | ||||
| elixir 1.15.6-otp-26 | ||||
| erlang 26.1.2 | ||||
| nodejs 20.6.0 | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| FROM elixir:1.14.1-alpine AS build | ||||
| FROM elixir:1.15.6-alpine AS build | ||||
|  | ||||
| # install build dependencies | ||||
| RUN apk add --no-cache build-base npm git python3 | ||||
|   | ||||
| @@ -36,7 +36,7 @@ $fa-font-path: "@fortawesome/fontawesome-free/webfonts"; | ||||
| } | ||||
|  | ||||
| .invalid-feedback { | ||||
|   color: #a94442; | ||||
|   color: #f36c69; | ||||
|   display: block; | ||||
|   margin: -1rem 0 2rem; | ||||
| } | ||||
|   | ||||
| @@ -11,6 +11,7 @@ | ||||
|   } | ||||
|  | ||||
|   .checkbox { | ||||
|     @apply bg-primary-900; | ||||
|     -ms-transform: scale(1.5); | ||||
|     -moz-transform: scale(1.5); | ||||
|     -webkit-transform: scale(1.5); | ||||
| @@ -26,25 +27,31 @@ | ||||
|  | ||||
|   .btn { | ||||
|     @apply focus:outline-none px-4 py-2 rounded-lg; | ||||
|     @apply shadow-sm focus:shadow-lg; | ||||
|     @apply shadow-sm active:shadow-lg; | ||||
|     @apply border; | ||||
|     @apply transition-all duration-300 ease-in-out; | ||||
|   } | ||||
|  | ||||
|   .btn-primary { | ||||
|     @apply bg-primary-900 focus:bg-primary-900 active:bg-primary-800; | ||||
|     @apply bg-primary-900 active:bg-primary-800; | ||||
|     @apply border-primary-900 hover:border-primary-800 active:border-primary-700; | ||||
|     @apply text-primary-400; | ||||
|   } | ||||
|  | ||||
|   .btn-secondary { | ||||
|     @apply bg-primary-800 active:bg-primary-700; | ||||
|     @apply border-primary-800 hover:border-primary-700 active:border-primary-600; | ||||
|     @apply text-primary-400; | ||||
|   } | ||||
|  | ||||
|   .btn-alert { | ||||
|     @apply bg-red-700 focus:bg-red-800 active:bg-red-900; | ||||
|     @apply border-red-700 focus:border-red-800 active:border-red-900; | ||||
|     @apply bg-red-800 active:bg-red-900; | ||||
|     @apply border-red-800 active:border-red-900; | ||||
|     @apply text-primary-300; | ||||
|   } | ||||
|  | ||||
|   .hr { | ||||
|     @apply mx-auto border border-primary-600 w-full max-w-2xl; | ||||
|     @apply mx-auto border border-primary-600 w-full max-w-3xl; | ||||
|   } | ||||
|  | ||||
|   .link { | ||||
|   | ||||
| @@ -26,26 +26,19 @@ import 'phoenix_html' | ||||
| import { Socket } from 'phoenix' | ||||
| import { LiveSocket } from 'phoenix_live_view' | ||||
| import topbar from 'topbar' | ||||
| import MaintainAttrs from './maintain_attrs' | ||||
| import Alpine from 'alpinejs' | ||||
|  | ||||
| import CtrlEnter from './ctrlenter' | ||||
| import Date from './date' | ||||
| import DateTime from './datetime' | ||||
|  | ||||
| const csrfTokenElement = document.querySelector("meta[name='csrf-token']") | ||||
| let csrfToken | ||||
| if (csrfTokenElement) { csrfToken = csrfTokenElement.getAttribute('content') } | ||||
| const liveSocket = new LiveSocket('/live', Socket, { | ||||
|   dom: { | ||||
|     onBeforeElUpdated (from, to) { | ||||
|       if (from._x_dataStack) { window.Alpine.clone(from, to) } | ||||
|     } | ||||
|   }, | ||||
|   params: { _csrf_token: csrfToken }, | ||||
|   hooks: { MaintainAttrs } | ||||
|   hooks: { CtrlEnter, Date, DateTime } | ||||
| }) | ||||
|  | ||||
| // alpine.js | ||||
| window.Alpine = Alpine | ||||
| Alpine.start() | ||||
|  | ||||
| // Show progress bar on live navigation and form submits | ||||
| topbar.config({ barThickness: 1, barColors: { 0: '#fff' }, shadowColor: 'rgba(0, 0, 0, .3)' }) | ||||
| window.addEventListener('phx:page-loading-start', info => topbar.show()) | ||||
|   | ||||
							
								
								
									
										12
									
								
								assets/js/ctrlenter.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								assets/js/ctrlenter.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,12 @@ | ||||
| export default { | ||||
|   addFormSubmit (context) { | ||||
|     context.el.addEventListener('keydown', (e) => { | ||||
|       if (e.ctrlKey && e.key === 'Enter') { | ||||
|         context.el.dispatchEvent( | ||||
|           new Event('submit', { bubbles: true, cancelable: true })) | ||||
|       } | ||||
|     }) | ||||
|   }, | ||||
|   mounted () { this.addFormSubmit(this) }, | ||||
|   updated () { this.addFormSubmit(this) } | ||||
| } | ||||
							
								
								
									
										11
									
								
								assets/js/date.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								assets/js/date.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| export default { | ||||
|   displayDate (el) { | ||||
|     const date = | ||||
|       Intl.DateTimeFormat([], { timeZone: 'Etc/UTC', dateStyle: 'short' }) | ||||
|         .format(new Date(el.dateTime)) | ||||
|  | ||||
|     el.innerText = date | ||||
|   }, | ||||
|   mounted () { this.displayDate(this.el) }, | ||||
|   updated () { this.displayDate(this.el) } | ||||
| } | ||||
							
								
								
									
										11
									
								
								assets/js/datetime.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								assets/js/datetime.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| export default { | ||||
|   displayDateTime (el) { | ||||
|     const date = | ||||
|       Intl.DateTimeFormat([], { dateStyle: 'short', timeStyle: 'long' }) | ||||
|         .format(new Date(el.dateTime)) | ||||
|  | ||||
|     el.innerText = date | ||||
|   }, | ||||
|   mounted () { this.displayDateTime(this.el) }, | ||||
|   updated () { this.displayDateTime(this.el) } | ||||
| } | ||||
| @@ -1,22 +0,0 @@ | ||||
| // maintain user adjusted attributes, like textbox length on phoenix liveview | ||||
| // update. https://github.com/phoenixframework/phoenix_live_view/issues/1011 | ||||
|  | ||||
| export default { | ||||
|   attrs () { | ||||
|     if (this.el && this.el.getAttribute('data-attrs')) { | ||||
|       return this.el.getAttribute('data-attrs').split(', ') | ||||
|     } else { | ||||
|       return [] | ||||
|     } | ||||
|   }, | ||||
|   beforeUpdate () { | ||||
|     if (this.el) { | ||||
|       this.prevAttrs = this.attrs().map(name => [name, this.el.getAttribute(name)]) | ||||
|     } | ||||
|   }, | ||||
|   updated () { | ||||
|     if (this.el) { | ||||
|       this.prevAttrs.forEach(([name, val]) => this.el.setAttribute(name, val)) | ||||
|     } | ||||
|   } | ||||
| } | ||||
							
								
								
									
										21565
									
								
								assets/package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										21565
									
								
								assets/package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -3,8 +3,8 @@ | ||||
|   "description": " ", | ||||
|   "license": "MIT", | ||||
|   "engines": { | ||||
|     "node": "18.12.1", | ||||
|     "npm": "8.19.2" | ||||
|     "node": "v20.6.0", | ||||
|     "npm": "9.8.1" | ||||
|   }, | ||||
|   "scripts": { | ||||
|     "deploy": "NODE_ENV=production webpack --mode production", | ||||
| @@ -13,35 +13,35 @@ | ||||
|     "test": "standard" | ||||
|   }, | ||||
|   "dependencies": { | ||||
|     "@fontsource/nunito-sans": "^4.5.8", | ||||
|     "@fortawesome/fontawesome-free": "^6.1.1", | ||||
|     "alpinejs": "^3.10.2", | ||||
|     "@fontsource/nunito-sans": "^5.0.8", | ||||
|     "@fortawesome/fontawesome-free": "^6.4.2", | ||||
|     "phoenix": "file:../deps/phoenix", | ||||
|     "phoenix_html": "file:../deps/phoenix_html", | ||||
|     "phoenix_live_view": "file:../deps/phoenix_live_view", | ||||
|     "topbar": "^1.0.1" | ||||
|     "topbar": "^2.0.1" | ||||
|   }, | ||||
|   "devDependencies": { | ||||
|     "@babel/core": "^7.17.10", | ||||
|     "@babel/preset-env": "^7.17.10", | ||||
|     "autoprefixer": "^10.4.7", | ||||
|     "babel-loader": "^8.2.5", | ||||
|     "copy-webpack-plugin": "^10.2.4", | ||||
|     "css-loader": "^6.7.1", | ||||
|     "css-minimizer-webpack-plugin": "^3.4.1", | ||||
|     "@babel/core": "^7.23.2", | ||||
|     "@babel/preset-env": "^7.23.2", | ||||
|     "autoprefixer": "^10.4.16", | ||||
|     "babel-loader": "^9.1.3", | ||||
|     "copy-webpack-plugin": "^11.0.0", | ||||
|     "css-loader": "^6.8.1", | ||||
|     "css-minimizer-webpack-plugin": "^5.0.1", | ||||
|     "file-loader": "^6.2.0", | ||||
|     "mini-css-extract-plugin": "^2.6.0", | ||||
|     "postcss": "^8.4.13", | ||||
|     "postcss-import": "^14.1.0", | ||||
|     "postcss-loader": "^6.2.1", | ||||
|     "postcss-preset-env": "^7.5.0", | ||||
|     "sass": "^1.56.0", | ||||
|     "sass-loader": "^12.6.0", | ||||
|     "standard": "^17.0.0", | ||||
|     "tailwindcss": "^3.0.24", | ||||
|     "terser-webpack-plugin": "^5.3.1", | ||||
|     "webpack": "^5.72.0", | ||||
|     "webpack-cli": "^4.9.2", | ||||
|     "webpack-dev-server": "^4.9.0" | ||||
|     "mini-css-extract-plugin": "^2.7.6", | ||||
|     "npm-check-updates": "^16.14.6", | ||||
|     "postcss": "^8.4.31", | ||||
|     "postcss-import": "^15.1.0", | ||||
|     "postcss-loader": "^7.3.3", | ||||
|     "postcss-preset-env": "^9.3.0", | ||||
|     "sass": "^1.69.5", | ||||
|     "sass-loader": "^13.3.2", | ||||
|     "standard": "^17.1.0", | ||||
|     "tailwindcss": "^3.3.5", | ||||
|     "terser-webpack-plugin": "^5.3.9", | ||||
|     "webpack": "^5.89.0", | ||||
|     "webpack-cli": "^5.1.4", | ||||
|     "webpack-dev-server": "^4.15.1" | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -45,7 +45,7 @@ module.exports = (env, options) => { | ||||
|         { | ||||
|           test: /\.(woff(2)?|ttf|eot|svg|otf)(\?v=[0-9]\.[0-9]\.[0-9])?$/, | ||||
|           type: 'asset/resource', | ||||
|           generator: { filename: 'fonts/[name][ext]' } | ||||
|           generator: { filename: 'fonts/[name].[ext]' } | ||||
|         } | ||||
|       ] | ||||
|     }, | ||||
|   | ||||
							
								
								
									
										48
									
								
								changelog.md
									
									
									
									
									
								
							
							
						
						
									
										48
									
								
								changelog.md
									
									
									
									
									
								
							| @@ -1,3 +1,51 @@ | ||||
| # v0.1.14 | ||||
| - Fix issue with item content not able to be displayed sometimes | ||||
|  | ||||
| # v0.1.13 | ||||
| - Update dependencies | ||||
| - Fix debounces | ||||
| - Allow space as delimiter for tags | ||||
| - Add bottom padding to page | ||||
| - Make pipeline step not require content | ||||
| - Make content previews resizable | ||||
| - Fix live flashes not dismissable by click | ||||
| - Fix disconnection modal not displaying | ||||
| - Submit items with ctrl-enter | ||||
| - Display backlinks in pipeline description | ||||
| - Modify backlink format | ||||
|  | ||||
| # v0.1.12 | ||||
| - Code quality fixes | ||||
| - Fix error/404 pages not rendering properly | ||||
| - Update dependencies | ||||
|  | ||||
| # v0.1.11 | ||||
| - Update dependencies | ||||
| - ee cummings even more | ||||
| - Improve tests | ||||
| - Change invite path slightly | ||||
| - Disable arm builds since ci fails to build | ||||
|  | ||||
| # v0.1.10 | ||||
| - Improve accessibility | ||||
| - Code quality improvements | ||||
| - Fix dates displaying incorrectly | ||||
| - Add links to readme for github mirror | ||||
| - Add license (whoops) | ||||
| - Display links in note/context/step contents | ||||
|  | ||||
| # v0.1.9 | ||||
| - Improve server log | ||||
| - Style 大一點 | ||||
| - Various minor improvements | ||||
|  | ||||
| # v0.1.8 | ||||
| - Fix bug with public registration | ||||
| - Improve templates | ||||
| - Improve invites, record usage | ||||
| - Fix padding on more pages when using chrome | ||||
| - Add oban metrics to server log and live dashboard | ||||
|  | ||||
| # v0.1.7 | ||||
| - Update dependencies | ||||
| - Show topbar on form submit/page refresh | ||||
|   | ||||
| @@ -11,15 +11,19 @@ config :memex, | ||||
|   ecto_repos: [Memex.Repo], | ||||
|   generators: [binary_id: true] | ||||
|  | ||||
| config :memex, Memex.Accounts, registration: System.get_env("REGISTRATION", "invite") | ||||
|  | ||||
| # Configures the endpoint | ||||
| config :memex, MemexWeb.Endpoint, | ||||
|   url: [scheme: "https", host: System.get_env("HOST") || "localhost", port: "443"], | ||||
|   http: [port: String.to_integer(System.get_env("PORT") || "4000")], | ||||
|   secret_key_base: "KH59P0iZixX5gP/u+zkxxG8vAAj6vgt0YqnwEB5JP5K+E567SsqkCz69uWShjE7I", | ||||
|   render_errors: [view: MemexWeb.ErrorView, accepts: ~w(html json), layout: false], | ||||
|   render_errors: [ | ||||
|     formats: [html: MemexWeb.ErrorHTML, json: MemexWeb.ErrorJSON], | ||||
|     layout: false | ||||
|   ], | ||||
|   pubsub_server: Memex.PubSub, | ||||
|   live_view: [signing_salt: "zOLgd3lr"], | ||||
|   registration: System.get_env("REGISTRATION") || "invite" | ||||
|   live_view: [signing_salt: "zOLgd3lr"] | ||||
|  | ||||
| config :memex, Memex.Application, automigrate: false | ||||
|  | ||||
|   | ||||
| @@ -59,13 +59,13 @@ config :memex, MemexWeb.Endpoint, | ||||
|     patterns: [ | ||||
|       ~r"priv/static/.*(js|css|png|jpeg|jpg|gif|svg)$", | ||||
|       ~r"priv/gettext/.*(po)$", | ||||
|       ~r"lib/memex_web/(live|views)/.*(ex)$", | ||||
|       ~r"lib/memex_web/templates/.*(eex)$" | ||||
|       ~r"lib/memex_web/*/.*(ex)$" | ||||
|     ] | ||||
|   ] | ||||
|  | ||||
| # Do not include metadata nor timestamps in development logs | ||||
| config :logger, :console, format: "[$level] $message\n" | ||||
| config :logger, :console, | ||||
|   format: "[$level] $message $metadata\n\n", | ||||
|   metadata: [:data] | ||||
|  | ||||
| # Set a higher stacktrace during development. Avoid configuring such | ||||
| # in production as building large stacktraces may be expensive. | ||||
|   | ||||
| @@ -50,8 +50,11 @@ config :memex, MemexWeb.Endpoint, | ||||
|     ip: interface, | ||||
|     port: String.to_integer(System.get_env("PORT", "4000")) | ||||
|   ], | ||||
|   server: true, | ||||
|   registration: System.get_env("REGISTRATION", "invite") | ||||
|   server: true | ||||
|  | ||||
| if config_env() in [:dev, :prod] do | ||||
|   config :memex, Memex.Accounts, registration: System.get_env("REGISTRATION", "invite") | ||||
| end | ||||
|  | ||||
| if config_env() == :prod do | ||||
|   # The secret key base is used to sign/encrypt cookies and other secrets. | ||||
|   | ||||
| @@ -22,8 +22,11 @@ config :memex, MemexWeb.Endpoint, | ||||
| # In test we don't send emails. | ||||
| config :memex, Memex.Mailer, adapter: Swoosh.Adapters.Test | ||||
|  | ||||
| # Don't require invites for signups | ||||
| config :memex, Memex.Accounts, registration: "public" | ||||
|  | ||||
| # Print only warnings and errors during test | ||||
| config :logger, level: :warn | ||||
| config :logger, level: :warning | ||||
|  | ||||
| # Initialize plugs at runtime for faster test compilation | ||||
| config :phoenix, :plug_init_mode, :runtime | ||||
|   | ||||
							
								
								
									
										660
									
								
								lib/license.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										660
									
								
								lib/license.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,660 @@ | ||||
| ### GNU AFFERO GENERAL PUBLIC LICENSE | ||||
|  | ||||
| Version 3, 19 November 2007 | ||||
|  | ||||
| Copyright (C) 2007 Free Software Foundation, Inc. | ||||
| <https://fsf.org/> | ||||
|  | ||||
| Everyone is permitted to copy and distribute verbatim copies of this | ||||
| license document, but changing it is not allowed. | ||||
|  | ||||
| ### Preamble | ||||
|  | ||||
| The GNU Affero General Public License is a free, copyleft license for | ||||
| software and other kinds of works, specifically designed to ensure | ||||
| cooperation with the community in the case of network server software. | ||||
|  | ||||
| The licenses for most software and other practical works are designed | ||||
| to take away your freedom to share and change the works. By contrast, | ||||
| our General Public Licenses are intended to guarantee your freedom to | ||||
| share and change all versions of a program--to make sure it remains | ||||
| free software for all its users. | ||||
|  | ||||
| When we speak of free software, we are referring to freedom, not | ||||
| price. Our General Public Licenses are designed to make sure that you | ||||
| have the freedom to distribute copies of free software (and charge for | ||||
| them if you wish), that you receive source code or can get it if you | ||||
| want it, that you can change the software or use pieces of it in new | ||||
| free programs, and that you know you can do these things. | ||||
|  | ||||
| Developers that use our General Public Licenses protect your rights | ||||
| with two steps: (1) assert copyright on the software, and (2) offer | ||||
| you this License which gives you legal permission to copy, distribute | ||||
| and/or modify the software. | ||||
|  | ||||
| A secondary benefit of defending all users' freedom is that | ||||
| improvements made in alternate versions of the program, if they | ||||
| receive widespread use, become available for other developers to | ||||
| incorporate. Many developers of free software are heartened and | ||||
| encouraged by the resulting cooperation. However, in the case of | ||||
| software used on network servers, this result may fail to come about. | ||||
| The GNU General Public License permits making a modified version and | ||||
| letting the public access it on a server without ever releasing its | ||||
| source code to the public. | ||||
|  | ||||
| The GNU Affero General Public License is designed specifically to | ||||
| ensure that, in such cases, the modified source code becomes available | ||||
| to the community. It requires the operator of a network server to | ||||
| provide the source code of the modified version running there to the | ||||
| users of that server. Therefore, public use of a modified version, on | ||||
| a publicly accessible server, gives the public access to the source | ||||
| code of the modified version. | ||||
|  | ||||
| An older license, called the Affero General Public License and | ||||
| published by Affero, was designed to accomplish similar goals. This is | ||||
| a different license, not a version of the Affero GPL, but Affero has | ||||
| released a new version of the Affero GPL which permits relicensing | ||||
| under this license. | ||||
|  | ||||
| The precise terms and conditions for copying, distribution and | ||||
| modification follow. | ||||
|  | ||||
| ### TERMS AND CONDITIONS | ||||
|  | ||||
| #### 0. Definitions. | ||||
|  | ||||
| "This License" refers to version 3 of the GNU Affero General Public | ||||
| License. | ||||
|  | ||||
| "Copyright" also means copyright-like laws that apply to other kinds | ||||
| of works, such as semiconductor masks. | ||||
|  | ||||
| "The Program" refers to any copyrightable work licensed under this | ||||
| License. Each licensee is addressed as "you". "Licensees" and | ||||
| "recipients" may be individuals or organizations. | ||||
|  | ||||
| To "modify" a work means to copy from or adapt all or part of the work | ||||
| in a fashion requiring copyright permission, other than the making of | ||||
| an exact copy. The resulting work is called a "modified version" of | ||||
| the earlier work or a work "based on" the earlier work. | ||||
|  | ||||
| A "covered work" means either the unmodified Program or a work based | ||||
| on the Program. | ||||
|  | ||||
| To "propagate" a work means to do anything with it that, without | ||||
| permission, would make you directly or secondarily liable for | ||||
| infringement under applicable copyright law, except executing it on a | ||||
| computer or modifying a private copy. Propagation includes copying, | ||||
| distribution (with or without modification), making available to the | ||||
| public, and in some countries other activities as well. | ||||
|  | ||||
| To "convey" a work means any kind of propagation that enables other | ||||
| parties to make or receive copies. Mere interaction with a user | ||||
| through a computer network, with no transfer of a copy, is not | ||||
| conveying. | ||||
|  | ||||
| An interactive user interface displays "Appropriate Legal Notices" to | ||||
| the extent that it includes a convenient and prominently visible | ||||
| feature that (1) displays an appropriate copyright notice, and (2) | ||||
| tells the user that there is no warranty for the work (except to the | ||||
| extent that warranties are provided), that licensees may convey the | ||||
| work under this License, and how to view a copy of this License. If | ||||
| the interface presents a list of user commands or options, such as a | ||||
| menu, a prominent item in the list meets this criterion. | ||||
|  | ||||
| #### 1. Source Code. | ||||
|  | ||||
| The "source code" for a work means the preferred form of the work for | ||||
| making modifications to it. "Object code" means any non-source form of | ||||
| a work. | ||||
|  | ||||
| A "Standard Interface" means an interface that either is an official | ||||
| standard defined by a recognized standards body, or, in the case of | ||||
| interfaces specified for a particular programming language, one that | ||||
| is widely used among developers working in that language. | ||||
|  | ||||
| The "System Libraries" of an executable work include anything, other | ||||
| than the work as a whole, that (a) is included in the normal form of | ||||
| packaging a Major Component, but which is not part of that Major | ||||
| Component, and (b) serves only to enable use of the work with that | ||||
| Major Component, or to implement a Standard Interface for which an | ||||
| implementation is available to the public in source code form. A | ||||
| "Major Component", in this context, means a major essential component | ||||
| (kernel, window system, and so on) of the specific operating system | ||||
| (if any) on which the executable work runs, or a compiler used to | ||||
| produce the work, or an object code interpreter used to run it. | ||||
|  | ||||
| The "Corresponding Source" for a work in object code form means all | ||||
| the source code needed to generate, install, and (for an executable | ||||
| work) run the object code and to modify the work, including scripts to | ||||
| control those activities. However, it does not include the work's | ||||
| System Libraries, or general-purpose tools or generally available free | ||||
| programs which are used unmodified in performing those activities but | ||||
| which are not part of the work. For example, Corresponding Source | ||||
| includes interface definition files associated with source files for | ||||
| the work, and the source code for shared libraries and dynamically | ||||
| linked subprograms that the work is specifically designed to require, | ||||
| such as by intimate data communication or control flow between those | ||||
| subprograms and other parts of the work. | ||||
|  | ||||
| The Corresponding Source need not include anything that users can | ||||
| regenerate automatically from other parts of the Corresponding Source. | ||||
|  | ||||
| The Corresponding Source for a work in source code form is that same | ||||
| work. | ||||
|  | ||||
| #### 2. Basic Permissions. | ||||
|  | ||||
| All rights granted under this License are granted for the term of | ||||
| copyright on the Program, and are irrevocable provided the stated | ||||
| conditions are met. This License explicitly affirms your unlimited | ||||
| permission to run the unmodified Program. The output from running a | ||||
| covered work is covered by this License only if the output, given its | ||||
| content, constitutes a covered work. This License acknowledges your | ||||
| rights of fair use or other equivalent, as provided by copyright law. | ||||
|  | ||||
| You may make, run and propagate covered works that you do not convey, | ||||
| without conditions so long as your license otherwise remains in force. | ||||
| You may convey covered works to others for the sole purpose of having | ||||
| them make modifications exclusively for you, or provide you with | ||||
| facilities for running those works, provided that you comply with the | ||||
| terms of this License in conveying all material for which you do not | ||||
| control copyright. Those thus making or running the covered works for | ||||
| you must do so exclusively on your behalf, under your direction and | ||||
| control, on terms that prohibit them from making any copies of your | ||||
| copyrighted material outside their relationship with you. | ||||
|  | ||||
| Conveying under any other circumstances is permitted solely under the | ||||
| conditions stated below. Sublicensing is not allowed; section 10 makes | ||||
| it unnecessary. | ||||
|  | ||||
| #### 3. Protecting Users' Legal Rights From Anti-Circumvention Law. | ||||
|  | ||||
| No covered work shall be deemed part of an effective technological | ||||
| measure under any applicable law fulfilling obligations under article | ||||
| 11 of the WIPO copyright treaty adopted on 20 December 1996, or | ||||
| similar laws prohibiting or restricting circumvention of such | ||||
| measures. | ||||
|  | ||||
| When you convey a covered work, you waive any legal power to forbid | ||||
| circumvention of technological measures to the extent such | ||||
| circumvention is effected by exercising rights under this License with | ||||
| respect to the covered work, and you disclaim any intention to limit | ||||
| operation or modification of the work as a means of enforcing, against | ||||
| the work's users, your or third parties' legal rights to forbid | ||||
| circumvention of technological measures. | ||||
|  | ||||
| #### 4. Conveying Verbatim Copies. | ||||
|  | ||||
| You may convey verbatim copies of the Program's source code as you | ||||
| receive it, in any medium, provided that you conspicuously and | ||||
| appropriately publish on each copy an appropriate copyright notice; | ||||
| keep intact all notices stating that this License and any | ||||
| non-permissive terms added in accord with section 7 apply to the code; | ||||
| keep intact all notices of the absence of any warranty; and give all | ||||
| recipients a copy of this License along with the Program. | ||||
|  | ||||
| You may charge any price or no price for each copy that you convey, | ||||
| and you may offer support or warranty protection for a fee. | ||||
|  | ||||
| #### 5. Conveying Modified Source Versions. | ||||
|  | ||||
| You may convey a work based on the Program, or the modifications to | ||||
| produce it from the Program, in the form of source code under the | ||||
| terms of section 4, provided that you also meet all of these | ||||
| conditions: | ||||
|  | ||||
| -   a) The work must carry prominent notices stating that you modified | ||||
|     it, and giving a relevant date. | ||||
| -   b) The work must carry prominent notices stating that it is | ||||
|     released under this License and any conditions added under | ||||
|     section 7. This requirement modifies the requirement in section 4 | ||||
|     to "keep intact all notices". | ||||
| -   c) You must license the entire work, as a whole, under this | ||||
|     License to anyone who comes into possession of a copy. This | ||||
|     License will therefore apply, along with any applicable section 7 | ||||
|     additional terms, to the whole of the work, and all its parts, | ||||
|     regardless of how they are packaged. This License gives no | ||||
|     permission to license the work in any other way, but it does not | ||||
|     invalidate such permission if you have separately received it. | ||||
| -   d) If the work has interactive user interfaces, each must display | ||||
|     Appropriate Legal Notices; however, if the Program has interactive | ||||
|     interfaces that do not display Appropriate Legal Notices, your | ||||
|     work need not make them do so. | ||||
|  | ||||
| A compilation of a covered work with other separate and independent | ||||
| works, which are not by their nature extensions of the covered work, | ||||
| and which are not combined with it such as to form a larger program, | ||||
| in or on a volume of a storage or distribution medium, is called an | ||||
| "aggregate" if the compilation and its resulting copyright are not | ||||
| used to limit the access or legal rights of the compilation's users | ||||
| beyond what the individual works permit. Inclusion of a covered work | ||||
| in an aggregate does not cause this License to apply to the other | ||||
| parts of the aggregate. | ||||
|  | ||||
| #### 6. Conveying Non-Source Forms. | ||||
|  | ||||
| You may convey a covered work in object code form under the terms of | ||||
| sections 4 and 5, provided that you also convey the machine-readable | ||||
| Corresponding Source under the terms of this License, in one of these | ||||
| ways: | ||||
|  | ||||
| -   a) Convey the object code in, or embodied in, a physical product | ||||
|     (including a physical distribution medium), accompanied by the | ||||
|     Corresponding Source fixed on a durable physical medium | ||||
|     customarily used for software interchange. | ||||
| -   b) Convey the object code in, or embodied in, a physical product | ||||
|     (including a physical distribution medium), accompanied by a | ||||
|     written offer, valid for at least three years and valid for as | ||||
|     long as you offer spare parts or customer support for that product | ||||
|     model, to give anyone who possesses the object code either (1) a | ||||
|     copy of the Corresponding Source for all the software in the | ||||
|     product that is covered by this License, on a durable physical | ||||
|     medium customarily used for software interchange, for a price no | ||||
|     more than your reasonable cost of physically performing this | ||||
|     conveying of source, or (2) access to copy the Corresponding | ||||
|     Source from a network server at no charge. | ||||
| -   c) Convey individual copies of the object code with a copy of the | ||||
|     written offer to provide the Corresponding Source. This | ||||
|     alternative is allowed only occasionally and noncommercially, and | ||||
|     only if you received the object code with such an offer, in accord | ||||
|     with subsection 6b. | ||||
| -   d) Convey the object code by offering access from a designated | ||||
|     place (gratis or for a charge), and offer equivalent access to the | ||||
|     Corresponding Source in the same way through the same place at no | ||||
|     further charge. You need not require recipients to copy the | ||||
|     Corresponding Source along with the object code. If the place to | ||||
|     copy the object code is a network server, the Corresponding Source | ||||
|     may be on a different server (operated by you or a third party) | ||||
|     that supports equivalent copying facilities, provided you maintain | ||||
|     clear directions next to the object code saying where to find the | ||||
|     Corresponding Source. Regardless of what server hosts the | ||||
|     Corresponding Source, you remain obligated to ensure that it is | ||||
|     available for as long as needed to satisfy these requirements. | ||||
| -   e) Convey the object code using peer-to-peer transmission, | ||||
|     provided you inform other peers where the object code and | ||||
|     Corresponding Source of the work are being offered to the general | ||||
|     public at no charge under subsection 6d. | ||||
|  | ||||
| A separable portion of the object code, whose source code is excluded | ||||
| from the Corresponding Source as a System Library, need not be | ||||
| included in conveying the object code work. | ||||
|  | ||||
| A "User Product" is either (1) a "consumer product", which means any | ||||
| tangible personal property which is normally used for personal, | ||||
| family, or household purposes, or (2) anything designed or sold for | ||||
| incorporation into a dwelling. In determining whether a product is a | ||||
| consumer product, doubtful cases shall be resolved in favor of | ||||
| coverage. For a particular product received by a particular user, | ||||
| "normally used" refers to a typical or common use of that class of | ||||
| product, regardless of the status of the particular user or of the way | ||||
| in which the particular user actually uses, or expects or is expected | ||||
| to use, the product. A product is a consumer product regardless of | ||||
| whether the product has substantial commercial, industrial or | ||||
| non-consumer uses, unless such uses represent the only significant | ||||
| mode of use of the product. | ||||
|  | ||||
| "Installation Information" for a User Product means any methods, | ||||
| procedures, authorization keys, or other information required to | ||||
| install and execute modified versions of a covered work in that User | ||||
| Product from a modified version of its Corresponding Source. The | ||||
| information must suffice to ensure that the continued functioning of | ||||
| the modified object code is in no case prevented or interfered with | ||||
| solely because modification has been made. | ||||
|  | ||||
| If you convey an object code work under this section in, or with, or | ||||
| specifically for use in, a User Product, and the conveying occurs as | ||||
| part of a transaction in which the right of possession and use of the | ||||
| User Product is transferred to the recipient in perpetuity or for a | ||||
| fixed term (regardless of how the transaction is characterized), the | ||||
| Corresponding Source conveyed under this section must be accompanied | ||||
| by the Installation Information. But this requirement does not apply | ||||
| if neither you nor any third party retains the ability to install | ||||
| modified object code on the User Product (for example, the work has | ||||
| been installed in ROM). | ||||
|  | ||||
| The requirement to provide Installation Information does not include a | ||||
| requirement to continue to provide support service, warranty, or | ||||
| updates for a work that has been modified or installed by the | ||||
| recipient, or for the User Product in which it has been modified or | ||||
| installed. Access to a network may be denied when the modification | ||||
| itself materially and adversely affects the operation of the network | ||||
| or violates the rules and protocols for communication across the | ||||
| network. | ||||
|  | ||||
| Corresponding Source conveyed, and Installation Information provided, | ||||
| in accord with this section must be in a format that is publicly | ||||
| documented (and with an implementation available to the public in | ||||
| source code form), and must require no special password or key for | ||||
| unpacking, reading or copying. | ||||
|  | ||||
| #### 7. Additional Terms. | ||||
|  | ||||
| "Additional permissions" are terms that supplement the terms of this | ||||
| License by making exceptions from one or more of its conditions. | ||||
| Additional permissions that are applicable to the entire Program shall | ||||
| be treated as though they were included in this License, to the extent | ||||
| that they are valid under applicable law. If additional permissions | ||||
| apply only to part of the Program, that part may be used separately | ||||
| under those permissions, but the entire Program remains governed by | ||||
| this License without regard to the additional permissions. | ||||
|  | ||||
| When you convey a copy of a covered work, you may at your option | ||||
| remove any additional permissions from that copy, or from any part of | ||||
| it. (Additional permissions may be written to require their own | ||||
| removal in certain cases when you modify the work.) You may place | ||||
| additional permissions on material, added by you to a covered work, | ||||
| for which you have or can give appropriate copyright permission. | ||||
|  | ||||
| Notwithstanding any other provision of this License, for material you | ||||
| add to a covered work, you may (if authorized by the copyright holders | ||||
| of that material) supplement the terms of this License with terms: | ||||
|  | ||||
| -   a) Disclaiming warranty or limiting liability differently from the | ||||
|     terms of sections 15 and 16 of this License; or | ||||
| -   b) Requiring preservation of specified reasonable legal notices or | ||||
|     author attributions in that material or in the Appropriate Legal | ||||
|     Notices displayed by works containing it; or | ||||
| -   c) Prohibiting misrepresentation of the origin of that material, | ||||
|     or requiring that modified versions of such material be marked in | ||||
|     reasonable ways as different from the original version; or | ||||
| -   d) Limiting the use for publicity purposes of names of licensors | ||||
|     or authors of the material; or | ||||
| -   e) Declining to grant rights under trademark law for use of some | ||||
|     trade names, trademarks, or service marks; or | ||||
| -   f) Requiring indemnification of licensors and authors of that | ||||
|     material by anyone who conveys the material (or modified versions | ||||
|     of it) with contractual assumptions of liability to the recipient, | ||||
|     for any liability that these contractual assumptions directly | ||||
|     impose on those licensors and authors. | ||||
|  | ||||
| All other non-permissive additional terms are considered "further | ||||
| restrictions" within the meaning of section 10. If the Program as you | ||||
| received it, or any part of it, contains a notice stating that it is | ||||
| governed by this License along with a term that is a further | ||||
| restriction, you may remove that term. If a license document contains | ||||
| a further restriction but permits relicensing or conveying under this | ||||
| License, you may add to a covered work material governed by the terms | ||||
| of that license document, provided that the further restriction does | ||||
| not survive such relicensing or conveying. | ||||
|  | ||||
| If you add terms to a covered work in accord with this section, you | ||||
| must place, in the relevant source files, a statement of the | ||||
| additional terms that apply to those files, or a notice indicating | ||||
| where to find the applicable terms. | ||||
|  | ||||
| Additional terms, permissive or non-permissive, may be stated in the | ||||
| form of a separately written license, or stated as exceptions; the | ||||
| above requirements apply either way. | ||||
|  | ||||
| #### 8. Termination. | ||||
|  | ||||
| You may not propagate or modify a covered work except as expressly | ||||
| provided under this License. Any attempt otherwise to propagate or | ||||
| modify it is void, and will automatically terminate your rights under | ||||
| this License (including any patent licenses granted under the third | ||||
| paragraph of section 11). | ||||
|  | ||||
| However, if you cease all violation of this License, then your license | ||||
| from a particular copyright holder is reinstated (a) provisionally, | ||||
| unless and until the copyright holder explicitly and finally | ||||
| terminates your license, and (b) permanently, if the copyright holder | ||||
| fails to notify you of the violation by some reasonable means prior to | ||||
| 60 days after the cessation. | ||||
|  | ||||
| Moreover, your license from a particular copyright holder is | ||||
| reinstated permanently if the copyright holder notifies you of the | ||||
| violation by some reasonable means, this is the first time you have | ||||
| received notice of violation of this License (for any work) from that | ||||
| copyright holder, and you cure the violation prior to 30 days after | ||||
| your receipt of the notice. | ||||
|  | ||||
| Termination of your rights under this section does not terminate the | ||||
| licenses of parties who have received copies or rights from you under | ||||
| this License. If your rights have been terminated and not permanently | ||||
| reinstated, you do not qualify to receive new licenses for the same | ||||
| material under section 10. | ||||
|  | ||||
| #### 9. Acceptance Not Required for Having Copies. | ||||
|  | ||||
| You are not required to accept this License in order to receive or run | ||||
| a copy of the Program. Ancillary propagation of a covered work | ||||
| occurring solely as a consequence of using peer-to-peer transmission | ||||
| to receive a copy likewise does not require acceptance. However, | ||||
| nothing other than this License grants you permission to propagate or | ||||
| modify any covered work. These actions infringe copyright if you do | ||||
| not accept this License. Therefore, by modifying or propagating a | ||||
| covered work, you indicate your acceptance of this License to do so. | ||||
|  | ||||
| #### 10. Automatic Licensing of Downstream Recipients. | ||||
|  | ||||
| Each time you convey a covered work, the recipient automatically | ||||
| receives a license from the original licensors, to run, modify and | ||||
| propagate that work, subject to this License. You are not responsible | ||||
| for enforcing compliance by third parties with this License. | ||||
|  | ||||
| An "entity transaction" is a transaction transferring control of an | ||||
| organization, or substantially all assets of one, or subdividing an | ||||
| organization, or merging organizations. If propagation of a covered | ||||
| work results from an entity transaction, each party to that | ||||
| transaction who receives a copy of the work also receives whatever | ||||
| licenses to the work the party's predecessor in interest had or could | ||||
| give under the previous paragraph, plus a right to possession of the | ||||
| Corresponding Source of the work from the predecessor in interest, if | ||||
| the predecessor has it or can get it with reasonable efforts. | ||||
|  | ||||
| You may not impose any further restrictions on the exercise of the | ||||
| rights granted or affirmed under this License. For example, you may | ||||
| not impose a license fee, royalty, or other charge for exercise of | ||||
| rights granted under this License, and you may not initiate litigation | ||||
| (including a cross-claim or counterclaim in a lawsuit) alleging that | ||||
| any patent claim is infringed by making, using, selling, offering for | ||||
| sale, or importing the Program or any portion of it. | ||||
|  | ||||
| #### 11. Patents. | ||||
|  | ||||
| A "contributor" is a copyright holder who authorizes use under this | ||||
| License of the Program or a work on which the Program is based. The | ||||
| work thus licensed is called the contributor's "contributor version". | ||||
|  | ||||
| A contributor's "essential patent claims" are all patent claims owned | ||||
| or controlled by the contributor, whether already acquired or | ||||
| hereafter acquired, that would be infringed by some manner, permitted | ||||
| by this License, of making, using, or selling its contributor version, | ||||
| but do not include claims that would be infringed only as a | ||||
| consequence of further modification of the contributor version. For | ||||
| purposes of this definition, "control" includes the right to grant | ||||
| patent sublicenses in a manner consistent with the requirements of | ||||
| this License. | ||||
|  | ||||
| Each contributor grants you a non-exclusive, worldwide, royalty-free | ||||
| patent license under the contributor's essential patent claims, to | ||||
| make, use, sell, offer for sale, import and otherwise run, modify and | ||||
| propagate the contents of its contributor version. | ||||
|  | ||||
| In the following three paragraphs, a "patent license" is any express | ||||
| agreement or commitment, however denominated, not to enforce a patent | ||||
| (such as an express permission to practice a patent or covenant not to | ||||
| sue for patent infringement). To "grant" such a patent license to a | ||||
| party means to make such an agreement or commitment not to enforce a | ||||
| patent against the party. | ||||
|  | ||||
| If you convey a covered work, knowingly relying on a patent license, | ||||
| and the Corresponding Source of the work is not available for anyone | ||||
| to copy, free of charge and under the terms of this License, through a | ||||
| publicly available network server or other readily accessible means, | ||||
| then you must either (1) cause the Corresponding Source to be so | ||||
| available, or (2) arrange to deprive yourself of the benefit of the | ||||
| patent license for this particular work, or (3) arrange, in a manner | ||||
| consistent with the requirements of this License, to extend the patent | ||||
| license to downstream recipients. "Knowingly relying" means you have | ||||
| actual knowledge that, but for the patent license, your conveying the | ||||
| covered work in a country, or your recipient's use of the covered work | ||||
| in a country, would infringe one or more identifiable patents in that | ||||
| country that you have reason to believe are valid. | ||||
|  | ||||
| If, pursuant to or in connection with a single transaction or | ||||
| arrangement, you convey, or propagate by procuring conveyance of, a | ||||
| covered work, and grant a patent license to some of the parties | ||||
| receiving the covered work authorizing them to use, propagate, modify | ||||
| or convey a specific copy of the covered work, then the patent license | ||||
| you grant is automatically extended to all recipients of the covered | ||||
| work and works based on it. | ||||
|  | ||||
| A patent license is "discriminatory" if it does not include within the | ||||
| scope of its coverage, prohibits the exercise of, or is conditioned on | ||||
| the non-exercise of one or more of the rights that are specifically | ||||
| granted under this License. You may not convey a covered work if you | ||||
| are a party to an arrangement with a third party that is in the | ||||
| business of distributing software, under which you make payment to the | ||||
| third party based on the extent of your activity of conveying the | ||||
| work, and under which the third party grants, to any of the parties | ||||
| who would receive the covered work from you, a discriminatory patent | ||||
| license (a) in connection with copies of the covered work conveyed by | ||||
| you (or copies made from those copies), or (b) primarily for and in | ||||
| connection with specific products or compilations that contain the | ||||
| covered work, unless you entered into that arrangement, or that patent | ||||
| license was granted, prior to 28 March 2007. | ||||
|  | ||||
| Nothing in this License shall be construed as excluding or limiting | ||||
| any implied license or other defenses to infringement that may | ||||
| otherwise be available to you under applicable patent law. | ||||
|  | ||||
| #### 12. No Surrender of Others' Freedom. | ||||
|  | ||||
| If conditions are imposed on you (whether by court order, agreement or | ||||
| otherwise) that contradict the conditions of this License, they do not | ||||
| excuse you from the conditions of this License. If you cannot convey a | ||||
| covered work so as to satisfy simultaneously your obligations under | ||||
| this License and any other pertinent obligations, then as a | ||||
| consequence you may not convey it at all. For example, if you agree to | ||||
| terms that obligate you to collect a royalty for further conveying | ||||
| from those to whom you convey the Program, the only way you could | ||||
| satisfy both those terms and this License would be to refrain entirely | ||||
| from conveying the Program. | ||||
|  | ||||
| #### 13. Remote Network Interaction; Use with the GNU General Public License. | ||||
|  | ||||
| Notwithstanding any other provision of this License, if you modify the | ||||
| Program, your modified version must prominently offer all users | ||||
| interacting with it remotely through a computer network (if your | ||||
| version supports such interaction) an opportunity to receive the | ||||
| Corresponding Source of your version by providing access to the | ||||
| Corresponding Source from a network server at no charge, through some | ||||
| standard or customary means of facilitating copying of software. This | ||||
| Corresponding Source shall include the Corresponding Source for any | ||||
| work covered by version 3 of the GNU General Public License that is | ||||
| incorporated pursuant to the following paragraph. | ||||
|  | ||||
| Notwithstanding any other provision of this License, you have | ||||
| permission to link or combine any covered work with a work licensed | ||||
| under version 3 of the GNU General Public License into a single | ||||
| combined work, and to convey the resulting work. The terms of this | ||||
| License will continue to apply to the part which is the covered work, | ||||
| but the work with which it is combined will remain governed by version | ||||
| 3 of the GNU General Public License. | ||||
|  | ||||
| #### 14. Revised Versions of this License. | ||||
|  | ||||
| The Free Software Foundation may publish revised and/or new versions | ||||
| of the GNU Affero General Public License from time to time. Such new | ||||
| versions will be similar in spirit to the present version, but may | ||||
| differ in detail to address new problems or concerns. | ||||
|  | ||||
| Each version is given a distinguishing version number. If the Program | ||||
| specifies that a certain numbered version of the GNU Affero General | ||||
| Public License "or any later version" applies to it, you have the | ||||
| option of following the terms and conditions either of that numbered | ||||
| version or of any later version published by the Free Software | ||||
| Foundation. If the Program does not specify a version number of the | ||||
| GNU Affero General Public License, you may choose any version ever | ||||
| published by the Free Software Foundation. | ||||
|  | ||||
| If the Program specifies that a proxy can decide which future versions | ||||
| of the GNU Affero General Public License can be used, that proxy's | ||||
| public statement of acceptance of a version permanently authorizes you | ||||
| to choose that version for the Program. | ||||
|  | ||||
| Later license versions may give you additional or different | ||||
| permissions. However, no additional obligations are imposed on any | ||||
| author or copyright holder as a result of your choosing to follow a | ||||
| later version. | ||||
|  | ||||
| #### 15. Disclaimer of Warranty. | ||||
|  | ||||
| THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY | ||||
| APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT | ||||
| HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT | ||||
| WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT | ||||
| LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR | ||||
| A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND | ||||
| PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE | ||||
| DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR | ||||
| CORRECTION. | ||||
|  | ||||
| #### 16. Limitation of Liability. | ||||
|  | ||||
| IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING | ||||
| WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR | ||||
| CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, | ||||
| INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES | ||||
| ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT | ||||
| NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR | ||||
| LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM | ||||
| TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER | ||||
| PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. | ||||
|  | ||||
| #### 17. Interpretation of Sections 15 and 16. | ||||
|  | ||||
| If the disclaimer of warranty and limitation of liability provided | ||||
| above cannot be given local legal effect according to their terms, | ||||
| reviewing courts shall apply local law that most closely approximates | ||||
| an absolute waiver of all civil liability in connection with the | ||||
| Program, unless a warranty or assumption of liability accompanies a | ||||
| copy of the Program in return for a fee. | ||||
|  | ||||
| END OF TERMS AND CONDITIONS | ||||
|  | ||||
| ### How to Apply These Terms to Your New Programs | ||||
|  | ||||
| If you develop a new program, and you want it to be of the greatest | ||||
| possible use to the public, the best way to achieve this is to make it | ||||
| free software which everyone can redistribute and change under these | ||||
| terms. | ||||
|  | ||||
| To do so, attach the following notices to the program. It is safest to | ||||
| attach them to the start of each source file to most effectively state | ||||
| the exclusion of warranty; and each file should have at least the | ||||
| "copyright" line and a pointer to where the full notice is found. | ||||
|  | ||||
|         <one line to give the program's name and a brief idea of what it does.> | ||||
|         Copyright (C) <year>  <name of author> | ||||
|  | ||||
|         This program is free software: you can redistribute it and/or modify | ||||
|         it under the terms of the GNU Affero General Public License as | ||||
|         published by the Free Software Foundation, either version 3 of the | ||||
|         License, or (at your option) any later version. | ||||
|  | ||||
|         This program is distributed in the hope that it will be useful, | ||||
|         but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||
|         MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the | ||||
|         GNU Affero General Public License for more details. | ||||
|  | ||||
|         You should have received a copy of the GNU Affero General Public License | ||||
|         along with this program.  If not, see <https://www.gnu.org/licenses/>. | ||||
|  | ||||
| Also add information on how to contact you by electronic and paper | ||||
| mail. | ||||
|  | ||||
| If your software can interact with users remotely through a computer | ||||
| network, you should also make sure that it provides a way for users to | ||||
| get its source. For example, if your program is a web application, its | ||||
| interface could display a "Source" link that leads users to an archive | ||||
| of the code. There are many ways you could offer source, and different | ||||
| solutions will be better for different programs; see section 13 for | ||||
| the specific requirements. | ||||
|  | ||||
| You should also get your employer (if you work as a programmer) or | ||||
| school, if any, to sign a "copyright disclaimer" for the program, if | ||||
| necessary. For more information on this, and how to apply and follow | ||||
| the GNU AGPL, see <https://www.gnu.org/licenses/>. | ||||
| @@ -5,7 +5,7 @@ defmodule Memex.Accounts do | ||||
|  | ||||
|   import Ecto.Query, warn: false | ||||
|   alias Memex.{Mailer, Repo} | ||||
|   alias Memex.Accounts.{User, UserToken} | ||||
|   alias Memex.Accounts.{Invite, Invites, User, UserToken} | ||||
|   alias Ecto.{Changeset, Multi} | ||||
|   alias Oban.Job | ||||
|  | ||||
| @@ -24,14 +24,16 @@ defmodule Memex.Accounts do | ||||
|  | ||||
|   """ | ||||
|   @spec get_user_by_email(email :: String.t()) :: User.t() | nil | ||||
|   def get_user_by_email(email) when is_binary(email), do: Repo.get_by(User, email: email) | ||||
|   def get_user_by_email(email) when is_binary(email) do | ||||
|     Repo.get_by(User, email: email) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Gets a user by email and password. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> get_user_by_email_and_password("foo@example.com", "correct_password") | ||||
|       iex> get_user_by_email_and_password("foo@example.com", "valid_password") | ||||
|       %User{} | ||||
|  | ||||
|       iex> get_user_by_email_and_password("foo@example.com", "invalid_password") | ||||
| @@ -53,28 +55,30 @@ defmodule Memex.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> get_user!(123) | ||||
|       %User{} | ||||
|       iex> get_user!(user_id) | ||||
|       user | ||||
|  | ||||
|       iex> get_user!(456) | ||||
|       iex> get_user!() | ||||
|       ** (Ecto.NoResultsError) | ||||
|  | ||||
|   """ | ||||
|   @spec get_user!(User.t()) :: User.t() | ||||
|   def get_user!(id), do: Repo.get!(User, id) | ||||
|   @spec get_user!(User.id()) :: User.t() | ||||
|   def get_user!(id) do | ||||
|     Repo.get!(User, id) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns all users grouped by role. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> list_users_by_role(%User{id: 123, role: :admin}) | ||||
|       [admin: [%User{}], user: [%User{}, %User{}]] | ||||
|       iex> list_all_users_by_role(user1) | ||||
|       %{admin: [%User{role: :admin}], user: [%User{role: :user}]} | ||||
|  | ||||
|   """ | ||||
|   @spec list_all_users_by_role(User.t()) :: %{String.t() => [User.t()]} | ||||
|   @spec list_all_users_by_role(User.t()) :: %{User.role() => [User.t()]} | ||||
|   def list_all_users_by_role(%User{role: :admin}) do | ||||
|     Repo.all(from u in User, order_by: u.email) |> Enum.group_by(fn user -> user.role end) | ||||
|     Repo.all(from u in User, order_by: u.email) |> Enum.group_by(fn %{role: role} -> role end) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
| @@ -82,13 +86,12 @@ defmodule Memex.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> list_users_by_role(%User{id: 123, role: :admin}) | ||||
|       [%User{}] | ||||
|       iex> list_users_by_role(:admin) | ||||
|       [%User{role: :admin}] | ||||
|  | ||||
|   """ | ||||
|   @spec list_users_by_role(User.role()) :: [User.t()] | ||||
|   def list_users_by_role(role) do | ||||
|     role = role |> to_string() | ||||
|   @spec list_users_by_role(:admin) :: [User.t()] | ||||
|   def list_users_by_role(:admin = role) do | ||||
|     Repo.all(from u in User, where: u.role == ^role, order_by: u.email) | ||||
|   end | ||||
|  | ||||
| @@ -99,26 +102,36 @@ defmodule Memex.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> register_user(%{field: value}) | ||||
|       {:ok, %User{}} | ||||
|       iex> register_user(%{email: "foo@example.com", password: "valid_password"}) | ||||
|       {:ok, %User{email: "foo@example.com"}} | ||||
|  | ||||
|       iex> register_user(%{field: bad_value}) | ||||
|       iex> register_user(%{email: "foo@example"}) | ||||
|       {:error, %Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   @spec register_user(attrs :: map()) :: {:ok, User.t()} | {:error, User.changeset()} | ||||
|   def register_user(attrs) do | ||||
|   @spec register_user(attrs :: map()) :: | ||||
|           {:ok, User.t()} | {:error, :invalid_token | User.changeset()} | ||||
|   @spec register_user(attrs :: map(), Invite.token() | nil) :: | ||||
|           {:ok, User.t()} | {:error, :invalid_token | User.changeset()} | ||||
|   def register_user(attrs, invite_token \\ nil) do | ||||
|     Multi.new() | ||||
|     |> Multi.one(:users_count, from(u in User, select: count(u.id), distinct: true)) | ||||
|     |> Multi.insert(:add_user, fn %{users_count: count} -> | ||||
|     |> Multi.run(:use_invite, fn _changes_so_far, _repo -> | ||||
|       if allow_registration?() and invite_token |> is_nil() do | ||||
|         {:ok, nil} | ||||
|       else | ||||
|         Invites.use_invite(invite_token) | ||||
|       end | ||||
|     end) | ||||
|     |> Multi.insert(:add_user, fn %{users_count: count, use_invite: invite} -> | ||||
|       # if no registered users, make first user an admin | ||||
|       role = if count == 0, do: "admin", else: "user" | ||||
|  | ||||
|       User.registration_changeset(attrs) |> User.role_changeset(role) | ||||
|       role = if count == 0, do: :admin, else: :user | ||||
|       User.registration_changeset(attrs, invite) |> User.role_changeset(role) | ||||
|     end) | ||||
|     |> Repo.transaction() | ||||
|     |> case do | ||||
|       {:ok, %{add_user: user}} -> {:ok, user} | ||||
|       {:error, :use_invite, :invalid_token, _changes_so_far} -> {:error, :invalid_token} | ||||
|       {:error, :add_user, changeset, _changes_so_far} -> {:error, changeset} | ||||
|     end | ||||
|   end | ||||
| @@ -128,14 +141,18 @@ defmodule Memex.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> change_user_registration(user) | ||||
|       %Changeset{data: %User{}} | ||||
|       iex> change_user_registration() | ||||
|       %Changeset{} | ||||
|  | ||||
|       iex> change_user_registration(%{password: "hi"} | ||||
|       %Changeset{} | ||||
|  | ||||
|   """ | ||||
|   @spec change_user_registration() :: User.changeset() | ||||
|   @spec change_user_registration(attrs :: map()) :: User.changeset() | ||||
|   def change_user_registration(attrs \\ %{}), | ||||
|     do: User.registration_changeset(attrs, hash_password: false) | ||||
|   def change_user_registration(attrs \\ %{}) do | ||||
|     User.registration_changeset(attrs, nil, hash_password: false) | ||||
|   end | ||||
|  | ||||
|   ## Settings | ||||
|  | ||||
| @@ -144,24 +161,29 @@ defmodule Memex.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> change_user_email(user) | ||||
|       %Changeset{data: %User{}} | ||||
|       iex> change_user_email(%User{email: "foo@example.com"}) | ||||
|       %Changeset{} | ||||
|  | ||||
|   """ | ||||
|   @spec change_user_email(User.t()) :: User.changeset() | ||||
|   @spec change_user_email(User.t(), attrs :: map()) :: User.changeset() | ||||
|   def change_user_email(user, attrs \\ %{}), do: User.email_changeset(user, attrs) | ||||
|   def change_user_email(user, attrs \\ %{}) do | ||||
|     User.email_changeset(user, attrs) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns an `%Changeset{}` for changing the user role. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> change_user_role(user) | ||||
|       %Changeset{data: %User{}} | ||||
|       iex> change_user_role(%User{}, :user) | ||||
|       %Changeset{} | ||||
|  | ||||
|   """ | ||||
|   @spec change_user_role(User.t(), User.role()) :: User.changeset() | ||||
|   def change_user_role(user, role), do: User.role_changeset(user, role) | ||||
|   def change_user_role(user, role) do | ||||
|     User.role_changeset(user, role) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Emulates that the email will change without actually changing | ||||
| @@ -169,14 +191,14 @@ defmodule Memex.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> apply_user_email(user, "valid password", %{email: ...}) | ||||
|       iex> apply_user_email(user, "valid_password", %{email: "new_email@account.com"}) | ||||
|       {:ok, %User{}} | ||||
|  | ||||
|       iex> apply_user_email(user, "invalid password", %{email: ...}) | ||||
|       iex> apply_user_email(user, "invalid password", %{email: "new_email@account"}) | ||||
|       {:error, %Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   @spec apply_user_email(User.t(), password :: String.t(), attrs :: map()) :: | ||||
|   @spec apply_user_email(User.t(), email :: String.t(), attrs :: map()) :: | ||||
|           {:ok, User.t()} | {:error, User.changeset()} | ||||
|   def apply_user_email(user, password, attrs) do | ||||
|     user | ||||
| @@ -200,7 +222,7 @@ defmodule Memex.Accounts do | ||||
|          {:ok, _} <- Repo.transaction(user_email_multi(user, email, context)) do | ||||
|       :ok | ||||
|     else | ||||
|       _ -> :error | ||||
|       _error_tuple -> :error | ||||
|     end | ||||
|   end | ||||
|  | ||||
| @@ -218,8 +240,8 @@ defmodule Memex.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> deliver_update_email_instructions(user, current_email, &Routes.user_update_email_url(conn, :edit, &1)) | ||||
|       {:ok, %{to: ..., body: ...}} | ||||
|       iex> deliver_update_email_instructions(user, "new_foo@example.com", fn _token -> "example url" end) | ||||
|       %Oban.Job{args: %{email: :update_email, user_id: ^user_id, attrs: %{url: "example url"}}} | ||||
|  | ||||
|   """ | ||||
|   @spec deliver_update_email_instructions(User.t(), current_email :: String.t(), function) :: | ||||
| @@ -236,27 +258,31 @@ defmodule Memex.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> change_user_password(user) | ||||
|       %Changeset{data: %User{}} | ||||
|       iex> change_user_password(%User{}) | ||||
|       %Changeset{} | ||||
|  | ||||
|   """ | ||||
|   @spec change_user_password(User.t(), attrs :: map()) :: User.changeset() | ||||
|   def change_user_password(user, attrs \\ %{}), | ||||
|     do: User.password_changeset(user, attrs, hash_password: false) | ||||
|   def change_user_password(user, attrs \\ %{}) do | ||||
|     User.password_changeset(user, attrs, hash_password: false) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Updates the user password. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> update_user_password(user, "valid password", %{password: ...}) | ||||
|       iex> reset_user_password(user, %{ | ||||
|       ...>   password: "new password", | ||||
|       ...>   password_confirmation: "new password" | ||||
|       ...> }) | ||||
|       {:ok, %User{}} | ||||
|  | ||||
|       iex> update_user_password(user, "invalid password", %{password: ...}) | ||||
|       iex> update_user_password(user, "invalid password", %{password: "123"}) | ||||
|       {:error, %Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   @spec update_user_password(User.t(), password :: String.t(), attrs :: map()) :: | ||||
|   @spec update_user_password(User.t(), String.t(), attrs :: map()) :: | ||||
|           {:ok, User.t()} | {:error, User.changeset()} | ||||
|   def update_user_password(user, password, attrs) do | ||||
|     changeset = | ||||
| @@ -270,54 +296,59 @@ defmodule Memex.Accounts do | ||||
|     |> Repo.transaction() | ||||
|     |> case do | ||||
|       {:ok, %{user: user}} -> {:ok, user} | ||||
|       {:error, :user, changeset, _} -> {:error, changeset} | ||||
|       {:error, :user, changeset, _changes_so_far} -> {:error, changeset} | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns an `%Changeset{}` for changing the user locale. | ||||
|   Returns an `Ecto.Changeset.t()` for changing the user locale. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> change_user_locale(user) | ||||
|       %Changeset{data: %User{}} | ||||
|       iex> change_user_locale(%User{}) | ||||
|       %Changeset{} | ||||
|  | ||||
|   """ | ||||
|   @spec change_user_locale(User.t()) :: User.changeset() | ||||
|   def change_user_locale(%{locale: locale} = user), do: User.locale_changeset(user, locale) | ||||
|   def change_user_locale(%{locale: locale} = user) do | ||||
|     User.locale_changeset(user, locale) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Updates the user locale. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> update_user_locale(user, "valid locale") | ||||
|       iex> update_user_locale(user, "en_US") | ||||
|       {:ok, %User{}} | ||||
|  | ||||
|       iex> update_user_password(user, "invalid locale") | ||||
|       {:error, %Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   @spec update_user_locale(User.t(), locale :: String.t()) :: | ||||
|           {:ok, User.t()} | {:error, User.changeset()} | ||||
|   def update_user_locale(user, locale), | ||||
|     do: user |> User.locale_changeset(locale) |> Repo.update() | ||||
|   def update_user_locale(user, locale) do | ||||
|     user |> User.locale_changeset(locale) |> Repo.update() | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Deletes a user. must be performed by an admin or the same user! | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> delete_user!(user_to_delete, %User{id: 123, role: :admin}) | ||||
|       iex> delete_user!(user, %User{id: 123, role: :admin}) | ||||
|       %User{} | ||||
|  | ||||
|       iex> delete_user!(%User{id: 123}, %User{id: 123}) | ||||
|       iex> delete_user!(user, user) | ||||
|       %User{} | ||||
|  | ||||
|   """ | ||||
|   @spec delete_user!(user_to_delete :: User.t(), User.t()) :: User.t() | ||||
|   def delete_user!(user, %User{role: :admin}), do: user |> Repo.delete!() | ||||
|   def delete_user!(%User{id: user_id} = user, %User{id: user_id}), do: user |> Repo.delete!() | ||||
|   def delete_user!(user, %User{role: :admin}) do | ||||
|     user |> Repo.delete!() | ||||
|   end | ||||
|  | ||||
|   def delete_user!(%User{id: user_id} = user, %User{id: user_id}) do | ||||
|     user |> Repo.delete!() | ||||
|   end | ||||
|  | ||||
|   ## Session | ||||
|  | ||||
| @@ -343,9 +374,9 @@ defmodule Memex.Accounts do | ||||
|   @doc """ | ||||
|   Deletes the signed token with the given context. | ||||
|   """ | ||||
|   @spec delete_session_token(token :: String.t()) :: :ok | ||||
|   def delete_session_token(token) do | ||||
|     Repo.delete_all(UserToken.token_and_context_query(token, "session")) | ||||
|   @spec delete_user_session_token(token :: String.t()) :: :ok | ||||
|   def delete_user_session_token(token) do | ||||
|     UserToken.token_and_context_query(token, "session") |> Repo.delete_all() | ||||
|     :ok | ||||
|   end | ||||
|  | ||||
| @@ -354,20 +385,49 @@ defmodule Memex.Accounts do | ||||
|   """ | ||||
|   @spec allow_registration?() :: boolean() | ||||
|   def allow_registration? do | ||||
|     Application.get_env(:memex, MemexWeb.Endpoint)[:registration] == "public" or | ||||
|     Application.get_env(:memex, Memex.Accounts)[:registration] == "public" or | ||||
|       list_users_by_role(:admin) |> Enum.empty?() | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns an atom representing the current configured registration mode | ||||
|   """ | ||||
|   @spec registration_mode() :: :public | :invite_only | ||||
|   def registration_mode do | ||||
|     case Application.get_env(:memex, Memex.Accounts)[:registration] do | ||||
|       "public" -> :public | ||||
|       _other -> :invite_only | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Checks if user is an admin | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> is_admin?(%User{role: :admin}) | ||||
|       true | ||||
|  | ||||
|       iex> is_admin?(%User{}) | ||||
|       false | ||||
|  | ||||
|   """ | ||||
|   @spec is_admin?(User.t()) :: boolean() | ||||
|   def is_admin?(%User{id: user_id}) do | ||||
|     Repo.exists?(from u in User, where: u.id == ^user_id and u.role == :admin) | ||||
|     Repo.exists?(from u in User, where: u.id == ^user_id, where: u.role == :admin) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Checks to see if user has the admin role | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> is_already_admin?(%User{role: :admin}) | ||||
|       true | ||||
|  | ||||
|       iex> is_already_admin?(%User{}) | ||||
|       false | ||||
|  | ||||
|   """ | ||||
|   @spec is_already_admin?(User.t() | nil) :: boolean() | ||||
|   def is_already_admin?(%User{role: :admin}), do: true | ||||
| @@ -380,10 +440,10 @@ defmodule Memex.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> deliver_user_confirmation_instructions(user, &Routes.user_confirmation_url(conn, :confirm, &1)) | ||||
|       {:ok, %{to: ..., body: ...}} | ||||
|       iex> deliver_user_confirmation_instructions(user, fn _token -> "example url" end) | ||||
|       %Oban.Job{args: %{email: :welcome, user_id: ^user_id, attrs: %{url: "example url"}}} | ||||
|  | ||||
|       iex> deliver_user_confirmation_instructions(confirmed_user, &Routes.user_confirmation_url(conn, :confirm, &1)) | ||||
|       iex> deliver_user_confirmation_instructions(user, fn _token -> "example url" end) | ||||
|       {:error, :already_confirmed} | ||||
|  | ||||
|   """ | ||||
| @@ -405,14 +465,14 @@ defmodule Memex.Accounts do | ||||
|   If the token matches, the user account is marked as confirmed | ||||
|   and the token is deleted. | ||||
|   """ | ||||
|   @spec confirm_user(token :: String.t()) :: {:ok, User.t()} | atom() | ||||
|   @spec confirm_user(token :: String.t()) :: {:ok, User.t()} | :error | ||||
|   def confirm_user(token) do | ||||
|     with {:ok, query} <- UserToken.verify_email_token_query(token, "confirm"), | ||||
|          %User{} = user <- Repo.one(query), | ||||
|          {:ok, %{user: user}} <- Repo.transaction(confirm_user_multi(user)) do | ||||
|       {:ok, user} | ||||
|     else | ||||
|       _ -> :error | ||||
|       _error_tuple -> :error | ||||
|     end | ||||
|   end | ||||
|  | ||||
| @@ -430,8 +490,8 @@ defmodule Memex.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> deliver_user_reset_password_instructions(user, &Routes.user_reset_password_url(conn, :edit, &1)) | ||||
|       {:ok, %{to: ..., body: ...}} | ||||
|       iex> deliver_user_reset_password_instructions(user, fn _token -> "example url" end) | ||||
|       %Oban.Job{args: %{email: :reset_password, user_id: ^user_id, attrs: %{url: "example url"}}} | ||||
|  | ||||
|   """ | ||||
|   @spec deliver_user_reset_password_instructions(User.t(), function()) :: Job.t() | ||||
| @@ -447,7 +507,7 @@ defmodule Memex.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> get_user_by_reset_password_token("validtoken") | ||||
|       iex> get_user_by_reset_password_token(encoded_token) | ||||
|       %User{} | ||||
|  | ||||
|       iex> get_user_by_reset_password_token("invalidtoken") | ||||
| @@ -460,7 +520,7 @@ defmodule Memex.Accounts do | ||||
|          %User{} = user <- Repo.one(query) do | ||||
|       user | ||||
|     else | ||||
|       _ -> nil | ||||
|       _error_tuple -> nil | ||||
|     end | ||||
|   end | ||||
|  | ||||
| @@ -469,7 +529,10 @@ defmodule Memex.Accounts do | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> reset_user_password(user, %{password: "new long password", password_confirmation: "new long password"}) | ||||
|       iex> reset_user_password(user, %{ | ||||
|       ...>   password: "new password", | ||||
|       ...>   password_confirmation: "new password" | ||||
|       ...> }) | ||||
|       {:ok, %User{}} | ||||
|  | ||||
|       iex> reset_user_password(user, %{password: "valid", password_confirmation: "not the same"}) | ||||
| @@ -485,7 +548,7 @@ defmodule Memex.Accounts do | ||||
|     |> Repo.transaction() | ||||
|     |> case do | ||||
|       {:ok, %{user: user}} -> {:ok, user} | ||||
|       {:error, :user, changeset, _} -> {:error, changeset} | ||||
|       {:error, :user, changeset, _changes_so_far} -> {:error, changeset} | ||||
|     end | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -3,14 +3,15 @@ defmodule Memex.Email do | ||||
|   Emails that can be sent using Swoosh. | ||||
|  | ||||
|   You can find the base email templates at | ||||
|   `lib/memex_web/templates/layout/email.html.heex` for html emails and | ||||
|   `lib/memex_web/templates/layout/email.txt.heex` for text emails. | ||||
|   `lib/memex_web/components/layouts/email_html.html.heex` for html emails and | ||||
|   `lib/memex_web/components/layouts/email_text.txt.eex` for text emails. | ||||
|   """ | ||||
|  | ||||
|   use Phoenix.Swoosh, view: MemexWeb.EmailView, layout: {MemexWeb.LayoutView, :email} | ||||
|   import Swoosh.Email | ||||
|   import MemexWeb.Gettext | ||||
|   import Phoenix.Template | ||||
|   alias Memex.Accounts.User | ||||
|   alias MemexWeb.EmailView | ||||
|   alias MemexWeb.{EmailHTML, Layouts} | ||||
|  | ||||
|   @typedoc """ | ||||
|   Represents an HTML and text body email that can be sent | ||||
| @@ -27,22 +28,29 @@ defmodule Memex.Email do | ||||
|   @spec generate_email(key :: String.t(), User.t(), attrs :: map()) :: t() | ||||
|   def generate_email("welcome", user, %{"url" => url}) do | ||||
|     user | ||||
|     |> base_email(dgettext("emails", "Confirm your Memex account")) | ||||
|     |> render_body("confirm_email.html", %{user: user, url: url}) | ||||
|     |> text_body(EmailView.render("confirm_email.txt", %{user: user, url: url})) | ||||
|     |> base_email(dgettext("emails", "confirm your memEx account")) | ||||
|     |> render_body(:confirm_email, %{user: user, url: url}) | ||||
|   end | ||||
|  | ||||
|   def generate_email("reset_password", user, %{"url" => url}) do | ||||
|     user | ||||
|     |> base_email(dgettext("emails", "Reset your Memex password")) | ||||
|     |> render_body("reset_password.html", %{user: user, url: url}) | ||||
|     |> text_body(EmailView.render("reset_password.txt", %{user: user, url: url})) | ||||
|     |> base_email(dgettext("emails", "reset your memEx password")) | ||||
|     |> render_body(:reset_password, %{user: user, url: url}) | ||||
|   end | ||||
|  | ||||
|   def generate_email("update_email", user, %{"url" => url}) do | ||||
|     user | ||||
|     |> base_email(dgettext("emails", "Update your Memex email")) | ||||
|     |> render_body("update_email.html", %{user: user, url: url}) | ||||
|     |> text_body(EmailView.render("update_email.txt", %{user: user, url: url})) | ||||
|     |> base_email(dgettext("emails", "update your memEx email")) | ||||
|     |> render_body(:update_email, %{user: user, url: url}) | ||||
|   end | ||||
|  | ||||
|   defp render_body(email, template, assigns) do | ||||
|     html_heex = apply(EmailHTML, String.to_existing_atom("#{template}_html"), [assigns]) | ||||
|     html = render_to_string(Layouts, "email_html", "html", email: email, inner_content: html_heex) | ||||
|  | ||||
|     text_heex = apply(EmailHTML, String.to_existing_atom("#{template}_text"), [assigns]) | ||||
|     text = render_to_string(Layouts, "email_text", "text", email: email, inner_content: text_heex) | ||||
|  | ||||
|     email |> html_body(html) |> text_body(text) | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -1,4 +1,4 @@ | ||||
| defmodule Memex.Invites.Invite do | ||||
| defmodule Memex.Accounts.Invite do | ||||
|   @moduledoc """ | ||||
|   An invite, created by an admin to allow someone to join their instance. An | ||||
|   invite can be enabled or disabled, and can have an optional number of uses if | ||||
| @@ -7,8 +7,8 @@ defmodule Memex.Invites.Invite do | ||||
| 
 | ||||
|   use Ecto.Schema | ||||
|   import Ecto.Changeset | ||||
|   alias Ecto.{Changeset, UUID} | ||||
|   alias Memex.{Accounts.User, Invites.Invite} | ||||
|   alias Ecto.{Association, Changeset, UUID} | ||||
|   alias Memex.Accounts.User | ||||
| 
 | ||||
|   @primary_key {:id, :binary_id, autogenerate: true} | ||||
|   @foreign_key_type :binary_id | ||||
| @@ -18,40 +18,46 @@ defmodule Memex.Invites.Invite do | ||||
|     field :uses_left, :integer, default: nil | ||||
|     field :disabled_at, :naive_datetime | ||||
| 
 | ||||
|     belongs_to :user, User | ||||
|     belongs_to :created_by, User | ||||
| 
 | ||||
|     has_many :users, User | ||||
| 
 | ||||
|     timestamps() | ||||
|   end | ||||
| 
 | ||||
|   @type t :: %Invite{ | ||||
|   @type t :: %__MODULE__{ | ||||
|           id: id(), | ||||
|           name: String.t(), | ||||
|           token: String.t(), | ||||
|           token: token(), | ||||
|           uses_left: integer() | nil, | ||||
|           disabled_at: NaiveDateTime.t(), | ||||
|           user: User.t(), | ||||
|           user_id: User.id(), | ||||
|           created_by: User.t() | nil | Association.NotLoaded.t(), | ||||
|           created_by_id: User.id() | nil, | ||||
|           users: [User.t()] | Association.NotLoaded.t(), | ||||
|           inserted_at: NaiveDateTime.t(), | ||||
|           updated_at: NaiveDateTime.t() | ||||
|         } | ||||
|   @type new_invite :: %Invite{} | ||||
|   @type new_invite :: %__MODULE__{} | ||||
|   @type id :: UUID.t() | ||||
|   @type changeset :: Changeset.t(t() | new_invite()) | ||||
|   @type token :: String.t() | ||||
| 
 | ||||
|   @doc false | ||||
|   @spec create_changeset(new_invite(), attrs :: map()) :: Changeset.t(new_invite()) | ||||
|   def create_changeset(invite, attrs) do | ||||
|     invite | ||||
|     |> cast(attrs, [:name, :token, :uses_left, :disabled_at, :user_id]) | ||||
|     |> validate_required([:name, :token, :user_id]) | ||||
|   @spec create_changeset(User.t(), token(), attrs :: map()) :: changeset() | ||||
|   def create_changeset(%User{id: user_id}, token, attrs) do | ||||
|     %__MODULE__{} | ||||
|     |> change(token: token, created_by_id: user_id) | ||||
|     |> cast(attrs, [:name, :uses_left, :disabled_at]) | ||||
|     |> validate_required([:name, :token, :created_by_id]) | ||||
|     |> validate_number(:uses_left, greater_than_or_equal_to: 0) | ||||
|   end | ||||
| 
 | ||||
|   @doc false | ||||
|   @spec update_changeset(t() | new_invite(), attrs :: map()) :: Changeset.t(t() | new_invite()) | ||||
|   @spec update_changeset(t() | new_invite(), attrs :: map()) :: changeset() | ||||
|   def update_changeset(invite, attrs) do | ||||
|     invite | ||||
|     |> cast(attrs, [:name, :uses_left, :disabled_at]) | ||||
|     |> validate_required([:name, :token, :user_id]) | ||||
|     |> validate_required([:name]) | ||||
|     |> validate_number(:uses_left, greater_than_or_equal_to: 0) | ||||
|   end | ||||
| end | ||||
							
								
								
									
										208
									
								
								lib/memex/accounts/invites.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										208
									
								
								lib/memex/accounts/invites.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,208 @@ | ||||
| defmodule Memex.Accounts.Invites do | ||||
|   @moduledoc """ | ||||
|   The Invites context. | ||||
|   """ | ||||
|  | ||||
|   import Ecto.Query, warn: false | ||||
|   alias Ecto.Multi | ||||
|   alias Memex.Accounts.{Invite, User} | ||||
|   alias Memex.Repo | ||||
|  | ||||
|   @invite_token_length 20 | ||||
|  | ||||
|   @doc """ | ||||
|   Returns the list of invites. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> list_invites(%User{id: 123, role: :admin}) | ||||
|       [%Invite{}, ...] | ||||
|  | ||||
|   """ | ||||
|   @spec list_invites(User.t()) :: [Invite.t()] | ||||
|   def list_invites(%User{role: :admin}) do | ||||
|     Repo.all(from i in Invite, order_by: i.name) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Gets a single invite for a user | ||||
|  | ||||
|   Raises `Ecto.NoResultsError` if the Invite does not exist. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> get_invite!(123, %User{id: 123, role: :admin}) | ||||
|       %Invite{} | ||||
|  | ||||
|       > get_invite!(456, %User{id: 123, role: :admin}) | ||||
|       ** (Ecto.NoResultsError) | ||||
|  | ||||
|   """ | ||||
|   @spec get_invite!(Invite.id(), User.t()) :: Invite.t() | ||||
|   def get_invite!(id, %User{role: :admin}) do | ||||
|     Repo.get!(Invite, id) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns if an invite token is still valid | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> valid_invite_token?("valid_token") | ||||
|       %Invite{} | ||||
|  | ||||
|       iex> valid_invite_token?("invalid_token") | ||||
|       nil | ||||
|   """ | ||||
|   @spec valid_invite_token?(Invite.token() | nil) :: boolean() | ||||
|   def valid_invite_token?(token) when token in [nil, ""], do: false | ||||
|  | ||||
|   def valid_invite_token?(token) do | ||||
|     Repo.exists?( | ||||
|       from i in Invite, | ||||
|         where: i.token == ^token, | ||||
|         where: i.disabled_at |> is_nil() | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Uses invite by decrementing uses_left, or marks invite invalid if it's been | ||||
|   completely used. | ||||
|   """ | ||||
|   @spec use_invite(Invite.token()) :: {:ok, Invite.t()} | {:error, :invalid_token} | ||||
|   def use_invite(invite_token) do | ||||
|     Multi.new() | ||||
|     |> Multi.run(:invite, fn _changes_so_far, _repo -> | ||||
|       invite_token |> get_invite_by_token() | ||||
|     end) | ||||
|     |> Multi.update(:decrement_invite, fn %{invite: invite} -> | ||||
|       decrement_invite_changeset(invite) | ||||
|     end) | ||||
|     |> Repo.transaction() | ||||
|     |> case do | ||||
|       {:ok, %{decrement_invite: invite}} -> {:ok, invite} | ||||
|       {:error, :invite, :invalid_token, _changes_so_far} -> {:error, :invalid_token} | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @spec get_invite_by_token(Invite.token() | nil) :: {:ok, Invite.t()} | {:error, :invalid_token} | ||||
|   defp get_invite_by_token(token) when token in [nil, ""], do: {:error, :invalid_token} | ||||
|  | ||||
|   defp get_invite_by_token(token) do | ||||
|     Repo.one( | ||||
|       from i in Invite, | ||||
|         where: i.token == ^token, | ||||
|         where: i.disabled_at |> is_nil() | ||||
|     ) | ||||
|     |> case do | ||||
|       nil -> {:error, :invalid_token} | ||||
|       invite -> {:ok, invite} | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @spec get_use_count(Invite.t(), User.t()) :: non_neg_integer() | nil | ||||
|   def get_use_count(%Invite{id: invite_id} = invite, user) do | ||||
|     [invite] |> get_use_counts(user) |> Map.get(invite_id) | ||||
|   end | ||||
|  | ||||
|   @spec get_use_counts([Invite.t()], User.t()) :: | ||||
|           %{optional(Invite.id()) => non_neg_integer()} | ||||
|   def get_use_counts(invites, %User{role: :admin}) do | ||||
|     invite_ids = invites |> Enum.map(fn %{id: invite_id} -> invite_id end) | ||||
|  | ||||
|     Repo.all( | ||||
|       from u in User, | ||||
|         where: u.invite_id in ^invite_ids, | ||||
|         group_by: u.invite_id, | ||||
|         select: {u.invite_id, count(u.id)} | ||||
|     ) | ||||
|     |> Map.new() | ||||
|   end | ||||
|  | ||||
|   @spec decrement_invite_changeset(Invite.t()) :: Invite.changeset() | ||||
|   defp decrement_invite_changeset(%Invite{uses_left: nil} = invite) do | ||||
|     invite |> Invite.update_changeset(%{}) | ||||
|   end | ||||
|  | ||||
|   defp decrement_invite_changeset(%Invite{uses_left: 1} = invite) do | ||||
|     now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) | ||||
|     invite |> Invite.update_changeset(%{uses_left: 0, disabled_at: now}) | ||||
|   end | ||||
|  | ||||
|   defp decrement_invite_changeset(%Invite{uses_left: uses_left} = invite) do | ||||
|     invite |> Invite.update_changeset(%{uses_left: uses_left - 1}) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Creates a invite. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> create_invite(%User{id: 123, role: :admin}, %{field: value}) | ||||
|       {:ok, %Invite{}} | ||||
|  | ||||
|       iex> create_invite(%User{id: 123, role: :admin}, %{field: bad_value}) | ||||
|       {:error, %Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   @spec create_invite(User.t(), attrs :: map()) :: | ||||
|           {:ok, Invite.t()} | {:error, Invite.changeset()} | ||||
|   def create_invite(%User{role: :admin} = user, attrs) do | ||||
|     token = | ||||
|       :crypto.strong_rand_bytes(@invite_token_length) | ||||
|       |> Base.url_encode64() | ||||
|       |> binary_part(0, @invite_token_length) | ||||
|  | ||||
|     Invite.create_changeset(user, token, attrs) |> Repo.insert() | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Updates a invite. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> update_invite(invite, %{field: new_value}, %User{id: 123, role: :admin}) | ||||
|       {:ok, %Invite{}} | ||||
|  | ||||
|       iex> update_invite(invite, %{field: bad_value}, %User{id: 123, role: :admin}) | ||||
|       {:error, %Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   @spec update_invite(Invite.t(), attrs :: map(), User.t()) :: | ||||
|           {:ok, Invite.t()} | {:error, Invite.changeset()} | ||||
|   def update_invite(invite, attrs, %User{role: :admin}) do | ||||
|     invite |> Invite.update_changeset(attrs) |> Repo.update() | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Deletes a invite. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> delete_invite(invite, %User{id: 123, role: :admin}) | ||||
|       {:ok, %Invite{}} | ||||
|  | ||||
|       iex> delete_invite(invite, %User{id: 123, role: :admin}) | ||||
|       {:error, %Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   @spec delete_invite(Invite.t(), User.t()) :: | ||||
|           {:ok, Invite.t()} | {:error, Invite.changeset()} | ||||
|   def delete_invite(invite, %User{role: :admin}) do | ||||
|     invite |> Repo.delete() | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Deletes a invite. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> delete_invite(invite, %User{id: 123, role: :admin}) | ||||
|       %Invite{} | ||||
|  | ||||
|   """ | ||||
|   @spec delete_invite!(Invite.t(), User.t()) :: Invite.t() | ||||
|   def delete_invite!(invite, %User{role: :admin}) do | ||||
|     invite |> Repo.delete!() | ||||
|   end | ||||
| end | ||||
| @@ -6,8 +6,8 @@ defmodule Memex.Accounts.User do | ||||
|   use Ecto.Schema | ||||
|   import Ecto.Changeset | ||||
|   import MemexWeb.Gettext | ||||
|   alias Ecto.{Changeset, UUID} | ||||
|   alias Memex.Invites.Invite | ||||
|   alias Ecto.{Association, Changeset, UUID} | ||||
|   alias Memex.Accounts.{Invite, User} | ||||
|  | ||||
|   @derive {Jason.Encoder, | ||||
|            only: [ | ||||
| @@ -30,27 +30,31 @@ defmodule Memex.Accounts.User do | ||||
|     field :role, Ecto.Enum, values: [:admin, :user], default: :user | ||||
|     field :locale, :string | ||||
|  | ||||
|     has_many :invites, Invite, on_delete: :delete_all | ||||
|     has_many :created_invites, Invite, foreign_key: :created_by_id | ||||
|  | ||||
|     belongs_to :invite, Invite | ||||
|  | ||||
|     timestamps() | ||||
|   end | ||||
|  | ||||
|   @type t :: %__MODULE__{ | ||||
|   @type t :: %User{ | ||||
|           id: id(), | ||||
|           email: String.t(), | ||||
|           password: String.t(), | ||||
|           hashed_password: String.t(), | ||||
|           confirmed_at: NaiveDateTime.t(), | ||||
|           role: role(), | ||||
|           invites: [Invite.t()], | ||||
|           locale: String.t() | nil, | ||||
|           created_invites: [Invite.t()] | Association.NotLoaded.t(), | ||||
|           invite: Invite.t() | nil | Association.NotLoaded.t(), | ||||
|           invite_id: Invite.id() | nil, | ||||
|           inserted_at: NaiveDateTime.t(), | ||||
|           updated_at: NaiveDateTime.t() | ||||
|         } | ||||
|   @type new_user :: %__MODULE__{} | ||||
|   @type new_user :: %User{} | ||||
|   @type id :: UUID.t() | ||||
|   @type changeset :: Changeset.t(t() | new_user()) | ||||
|   @type role :: :user | :admin | String.t() | ||||
|   @type role :: :admin | :user | ||||
|  | ||||
|   @doc """ | ||||
|   A user changeset for registration. | ||||
| @@ -69,22 +73,22 @@ defmodule Memex.Accounts.User do | ||||
|       validations on a LiveView form), this option can be set to `false`. | ||||
|       Defaults to `true`. | ||||
|   """ | ||||
|   @spec registration_changeset(attrs :: map()) :: changeset() | ||||
|   @spec registration_changeset(attrs :: map(), opts :: keyword()) :: changeset() | ||||
|   def registration_changeset(attrs, opts \\ []) do | ||||
|     %__MODULE__{} | ||||
|   @spec registration_changeset(attrs :: map(), Invite.t() | nil) :: changeset() | ||||
|   @spec registration_changeset(attrs :: map(), Invite.t() | nil, opts :: keyword()) :: changeset() | ||||
|   def registration_changeset(attrs, invite, opts \\ []) do | ||||
|     %User{} | ||||
|     |> cast(attrs, [:email, :password, :locale]) | ||||
|     |> put_change(:invite_id, if(invite, do: invite.id)) | ||||
|     |> validate_email() | ||||
|     |> validate_password(opts) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   A user changeset for role. | ||||
|  | ||||
|   """ | ||||
|   @spec role_changeset(t() | new_user() | changeset(), role()) :: changeset() | ||||
|   def role_changeset(user, role) do | ||||
|     user |> cast(%{"role" => role}, [:role]) | ||||
|     user |> change(role: role) | ||||
|   end | ||||
|  | ||||
|   @spec validate_email(changeset()) :: changeset() | ||||
| @@ -99,7 +103,8 @@ defmodule Memex.Accounts.User do | ||||
|     |> unique_constraint(:email) | ||||
|   end | ||||
|  | ||||
|   @spec validate_password(changeset(), opts :: keyword()) :: changeset() | ||||
|   @spec validate_password(changeset(), opts :: keyword()) :: | ||||
|           changeset() | ||||
|   defp validate_password(changeset, opts) do | ||||
|     changeset | ||||
|     |> validate_required([:password]) | ||||
| @@ -177,12 +182,12 @@ defmodule Memex.Accounts.User do | ||||
|   `Bcrypt.no_user_verify/0` to avoid timing attacks. | ||||
|   """ | ||||
|   @spec valid_password?(t(), String.t()) :: boolean() | ||||
|   def valid_password?(%__MODULE__{hashed_password: hashed_password}, password) | ||||
|   def valid_password?(%User{hashed_password: hashed_password}, password) | ||||
|       when is_binary(hashed_password) and byte_size(password) > 0 do | ||||
|     Bcrypt.verify_pass(password, hashed_password) | ||||
|   end | ||||
|  | ||||
|   def valid_password?(_, _) do | ||||
|   def valid_password?(_invalid_user, _invalid_password) do | ||||
|     Bcrypt.no_user_verify() | ||||
|     false | ||||
|   end | ||||
|   | ||||
| @@ -1,77 +0,0 @@ | ||||
| defmodule Memex.Accounts.UserNotifier do | ||||
|   @moduledoc """ | ||||
|   Contains templates and messages for user messages | ||||
|   """ | ||||
|  | ||||
|   # For simplicity, this module simply logs messages to the terminal. | ||||
|   # You should replace it by a proper email or notification tool, such as: | ||||
|   # | ||||
|   #   * Swoosh - https://hexdocs.pm/swoosh | ||||
|   #   * Bamboo - https://hexdocs.pm/bamboo | ||||
|   # | ||||
|   defp deliver(to, body) do | ||||
|     require Logger | ||||
|     Logger.debug(body) | ||||
|     {:ok, %{to: to, body: body}} | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Deliver instructions to confirm account. | ||||
|   """ | ||||
|   def deliver_confirmation_instructions(user, url) do | ||||
|     deliver(user.email, """ | ||||
|  | ||||
|     ============================== | ||||
|  | ||||
|     Hi #{user.email}, | ||||
|  | ||||
|     You can confirm your account by visiting the URL below: | ||||
|  | ||||
|     #{url} | ||||
|  | ||||
|     If you didn't create an account with us, please ignore this. | ||||
|  | ||||
|     ============================== | ||||
|     """) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Deliver instructions to reset a user password. | ||||
|   """ | ||||
|   def deliver_reset_password_instructions(user, url) do | ||||
|     deliver(user.email, """ | ||||
|  | ||||
|     ============================== | ||||
|  | ||||
|     Hi #{user.email}, | ||||
|  | ||||
|     You can reset your password by visiting the URL below: | ||||
|  | ||||
|     #{url} | ||||
|  | ||||
|     If you didn't request this change, please ignore this. | ||||
|  | ||||
|     ============================== | ||||
|     """) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Deliver instructions to update a user email. | ||||
|   """ | ||||
|   def deliver_update_email_instructions(user, url) do | ||||
|     deliver(user.email, """ | ||||
|  | ||||
|     ============================== | ||||
|  | ||||
|     Hi #{user.email}, | ||||
|  | ||||
|     You can change your email by visiting the URL below: | ||||
|  | ||||
|     #{url} | ||||
|  | ||||
|     If you didn't request this change, please ignore this. | ||||
|  | ||||
|     ============================== | ||||
|     """) | ||||
|   end | ||||
| end | ||||
| @@ -5,6 +5,8 @@ defmodule Memex.Accounts.UserToken do | ||||
|  | ||||
|   use Ecto.Schema | ||||
|   import Ecto.Query | ||||
|   alias Ecto.{Association, UUID} | ||||
|   alias Memex.Accounts.User | ||||
|  | ||||
|   @hash_algorithm :sha256 | ||||
|   @rand_size 32 | ||||
| @@ -22,11 +24,25 @@ defmodule Memex.Accounts.UserToken do | ||||
|     field :token, :binary | ||||
|     field :context, :string | ||||
|     field :sent_to, :string | ||||
|     belongs_to :user, Memex.Accounts.User | ||||
|  | ||||
|     belongs_to :user, User | ||||
|  | ||||
|     timestamps(updated_at: false) | ||||
|   end | ||||
|  | ||||
|   @type t :: %__MODULE__{ | ||||
|           id: id(), | ||||
|           token: token(), | ||||
|           context: String.t(), | ||||
|           sent_to: String.t(), | ||||
|           user: User.t() | Association.NotLoaded.t(), | ||||
|           user_id: User.id() | nil, | ||||
|           inserted_at: NaiveDateTime.t() | ||||
|         } | ||||
|   @type new_user_token :: %__MODULE__{} | ||||
|   @type id :: UUID.t() | ||||
|   @type token :: binary() | ||||
|  | ||||
|   @doc """ | ||||
|   Generates a token that will be stored in a signed place, | ||||
|   such as session or cookie. As they are signed, those | ||||
| @@ -34,7 +50,7 @@ defmodule Memex.Accounts.UserToken do | ||||
|   """ | ||||
|   def build_session_token(user) do | ||||
|     token = :crypto.strong_rand_bytes(@rand_size) | ||||
|     {token, %Memex.Accounts.UserToken{token: token, context: "session", user_id: user.id}} | ||||
|     {token, %__MODULE__{token: token, context: "session", user_id: user.id}} | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
| @@ -69,7 +85,7 @@ defmodule Memex.Accounts.UserToken do | ||||
|     hashed_token = :crypto.hash(@hash_algorithm, token) | ||||
|  | ||||
|     {Base.url_encode64(token, padding: false), | ||||
|      %Memex.Accounts.UserToken{ | ||||
|      %__MODULE__{ | ||||
|        token: hashed_token, | ||||
|        context: context, | ||||
|        sent_to: sent_to, | ||||
| @@ -129,17 +145,17 @@ defmodule Memex.Accounts.UserToken do | ||||
|   Returns the given token with the given context. | ||||
|   """ | ||||
|   def token_and_context_query(token, context) do | ||||
|     from Memex.Accounts.UserToken, where: [token: ^token, context: ^context] | ||||
|     from __MODULE__, where: [token: ^token, context: ^context] | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Gets all tokens for the given user for the given contexts. | ||||
|   """ | ||||
|   def user_and_contexts_query(user, :all) do | ||||
|     from t in Memex.Accounts.UserToken, where: t.user_id == ^user.id | ||||
|     from t in __MODULE__, where: t.user_id == ^user.id | ||||
|   end | ||||
|  | ||||
|   def user_and_contexts_query(user, [_ | _] = contexts) do | ||||
|     from t in Memex.Accounts.UserToken, where: t.user_id == ^user.id and t.context in ^contexts | ||||
|     from t in __MODULE__, where: t.user_id == ^user.id and t.context in ^contexts | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -4,6 +4,7 @@ defmodule Memex.Application do | ||||
|   @moduledoc false | ||||
|  | ||||
|   use Application | ||||
|   alias Memex.Logger | ||||
|  | ||||
|   @impl true | ||||
|   def start(_type, _args) do | ||||
| @@ -17,16 +18,24 @@ defmodule Memex.Application do | ||||
|       # Start the Endpoint (http/https) | ||||
|       MemexWeb.Endpoint, | ||||
|       # Add Oban | ||||
|       {Oban, oban_config()} | ||||
|       {Oban, oban_config()}, | ||||
|       Memex.Repo.Migrator | ||||
|       # Start a worker by calling: Memex.Worker.start_link(arg) | ||||
|       # {Memex.Worker, arg} | ||||
|     ] | ||||
|  | ||||
|     # Automatically migrate on start in prod | ||||
|     children = | ||||
|       if Application.get_env(:memex, Memex.Application, automigrate: false)[:automigrate], | ||||
|         do: children ++ [Memex.Repo.Migrator], | ||||
|         else: children | ||||
|     # Oban events logging https://hexdocs.pm/oban/Oban.html#module-reporting-errors | ||||
|     :ok = | ||||
|       :telemetry.attach_many( | ||||
|         "oban-logger", | ||||
|         [ | ||||
|           [:oban, :job, :exception], | ||||
|           [:oban, :job, :start], | ||||
|           [:oban, :job, :stop] | ||||
|         ], | ||||
|         &Logger.handle_event/4, | ||||
|         [] | ||||
|       ) | ||||
|  | ||||
|     # See https://hexdocs.pm/elixir/Supervisor.html | ||||
|     # for other strategies and supported options | ||||
|   | ||||
| @@ -228,4 +228,13 @@ defmodule Memex.Contexts do | ||||
|   def change_context(%Context{} = context, attrs \\ %{}, user) do | ||||
|     context |> Context.update_changeset(attrs, user) | ||||
|   end | ||||
|  | ||||
|   @spec is_owner_or_admin?(Context.t(), User.t()) :: boolean() | ||||
|   def is_owner_or_admin?(%{user_id: user_id}, %{id: user_id}), do: true | ||||
|   def is_owner_or_admin?(_context, %{role: :admin}), do: true | ||||
|   def is_owner_or_admin?(_context, _other_user), do: false | ||||
|  | ||||
|   @spec is_owner?(Context.t(), User.t()) :: boolean() | ||||
|   def is_owner?(%{user_id: user_id}, %{id: user_id}), do: true | ||||
|   def is_owner?(_context, _other_user), do: false | ||||
| end | ||||
|   | ||||
| @@ -9,6 +9,7 @@ defmodule Memex.Contexts.Context do | ||||
|   alias Ecto.{Changeset, UUID} | ||||
|   alias Memex.{Accounts.User, Repo} | ||||
|  | ||||
|   @derive {Phoenix.Param, key: :slug} | ||||
|   @derive {Jason.Encoder, | ||||
|            only: [ | ||||
|              :slug, | ||||
| @@ -27,7 +28,7 @@ defmodule Memex.Contexts.Context do | ||||
|     field :tags_string, :string, virtual: true | ||||
|     field :visibility, Ecto.Enum, values: [:public, :private, :unlisted] | ||||
|  | ||||
|     belongs_to :user, User | ||||
|     field :user_id, :binary_id | ||||
|  | ||||
|     timestamps() | ||||
|   end | ||||
| @@ -38,7 +39,6 @@ defmodule Memex.Contexts.Context do | ||||
|           tags: [String.t()] | nil, | ||||
|           tags_string: String.t() | nil, | ||||
|           visibility: :public | :private | :unlisted, | ||||
|           user: User.t() | Ecto.Association.NotLoaded.t(), | ||||
|           user_id: User.id(), | ||||
|           inserted_at: NaiveDateTime.t(), | ||||
|           updated_at: NaiveDateTime.t() | ||||
| @@ -79,11 +79,11 @@ defmodule Memex.Contexts.Context do | ||||
|     changeset | ||||
|     |> put_change(:tags_string, changeset |> get_field(:tags) |> get_tags_string()) | ||||
|     |> cast(attrs, [:tags_string]) | ||||
|     |> validate_format(:tags_string, ~r/^[\p{L}\p{N}\-\,]+$/, | ||||
|     |> validate_format(:tags_string, ~r/^[\p{L}\p{N}\-\, ]+$/, | ||||
|       message: | ||||
|         dgettext( | ||||
|           "errors", | ||||
|           "invalid format: only numbers, letters and hyphen are accepted. tags must be comma-delimited" | ||||
|           "invalid format: only numbers, letters and hyphen are accepted. tags must be comma or space delimited" | ||||
|         ) | ||||
|     ) | ||||
|     |> cast_tags() | ||||
| @@ -98,9 +98,9 @@ defmodule Memex.Contexts.Context do | ||||
|  | ||||
|   defp process_tags(tags_string) when tags_string |> is_binary() do | ||||
|     tags_string | ||||
|     |> String.split(",", trim: true) | ||||
|     |> String.split([",", " "], trim: true) | ||||
|     |> Enum.map(fn str -> str |> String.trim() end) | ||||
|     |> Enum.reject(fn str -> str |> is_nil() end) | ||||
|     |> Enum.reject(fn str -> str in [nil, ""] end) | ||||
|     |> Enum.sort() | ||||
|   end | ||||
|  | ||||
|   | ||||
| @@ -1,173 +0,0 @@ | ||||
| defmodule Memex.Invites do | ||||
|   @moduledoc """ | ||||
|   The Invites context. | ||||
|   """ | ||||
|  | ||||
|   import Ecto.Query, warn: false | ||||
|   alias Ecto.Changeset | ||||
|   alias Memex.{Accounts.User, Invites.Invite, Repo} | ||||
|  | ||||
|   @invite_token_length 20 | ||||
|  | ||||
|   @doc """ | ||||
|   Returns the list of invites. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> list_invites(%User{id: 123, role: :admin}) | ||||
|       [%Invite{}, ...] | ||||
|  | ||||
|   """ | ||||
|   @spec list_invites(User.t()) :: [Invite.t()] | ||||
|   def list_invites(%User{role: :admin}) do | ||||
|     Repo.all(from i in Invite, order_by: i.name) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Gets a single invite. | ||||
|  | ||||
|   Raises `Ecto.NoResultsError` if the Invite does not exist. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> get_invite!(123, %User{id: 123, role: :admin}) | ||||
|       %Invite{} | ||||
|  | ||||
|       iex> get_invite!(456, %User{id: 123, role: :admin}) | ||||
|       ** (Ecto.NoResultsError) | ||||
|  | ||||
|   """ | ||||
|   @spec get_invite!(Invite.id(), User.t()) :: Invite.t() | ||||
|   def get_invite!(id, %User{role: :admin}) do | ||||
|     Repo.get!(Invite, id) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Returns a valid invite or nil based on the attempted token | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> get_invite_by_token("valid_token") | ||||
|       %Invite{} | ||||
|  | ||||
|       iex> get_invite_by_token("invalid_token") | ||||
|       nil | ||||
|   """ | ||||
|   @spec get_invite_by_token(token :: String.t() | nil) :: Invite.t() | nil | ||||
|   def get_invite_by_token(nil), do: nil | ||||
|   def get_invite_by_token(""), do: nil | ||||
|  | ||||
|   def get_invite_by_token(token) do | ||||
|     Repo.one( | ||||
|       from(i in Invite, | ||||
|         where: i.token == ^token and i.disabled_at |> is_nil() | ||||
|       ) | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Uses invite by decrementing uses_left, or marks invite invalid if it's been | ||||
|   completely used. | ||||
|   """ | ||||
|   @spec use_invite!(Invite.t()) :: Invite.t() | ||||
|   def use_invite!(%Invite{uses_left: nil} = invite), do: invite | ||||
|  | ||||
|   def use_invite!(%Invite{uses_left: uses_left} = invite) do | ||||
|     new_uses_left = uses_left - 1 | ||||
|  | ||||
|     attrs = | ||||
|       if new_uses_left <= 0 do | ||||
|         now = NaiveDateTime.utc_now() |> NaiveDateTime.truncate(:second) | ||||
|         %{"uses_left" => 0, "disabled_at" => now} | ||||
|       else | ||||
|         %{"uses_left" => new_uses_left} | ||||
|       end | ||||
|  | ||||
|     invite |> Invite.update_changeset(attrs) |> Repo.update!() | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Creates a invite. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> create_invite(%User{id: 123, role: :admin}, %{field: value}) | ||||
|       {:ok, %Invite{}} | ||||
|  | ||||
|       iex> create_invite(%User{id: 123, role: :admin}, %{field: bad_value}) | ||||
|       {:error, %Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   @spec create_invite(User.t(), attrs :: map()) :: | ||||
|           {:ok, Invite.t()} | {:error, Changeset.t(Invite.new_invite())} | ||||
|   def create_invite(%User{id: user_id, role: :admin}, attrs) do | ||||
|     token = | ||||
|       :crypto.strong_rand_bytes(@invite_token_length) | ||||
|       |> Base.url_encode64() | ||||
|       |> binary_part(0, @invite_token_length) | ||||
|  | ||||
|     attrs = attrs |> Map.merge(%{"user_id" => user_id, "token" => token}) | ||||
|  | ||||
|     %Invite{} |> Invite.create_changeset(attrs) |> Repo.insert() | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Updates a invite. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> update_invite(invite, %{field: new_value}, %User{id: 123, role: :admin}) | ||||
|       {:ok, %Invite{}} | ||||
|  | ||||
|       iex> update_invite(invite, %{field: bad_value}, %User{id: 123, role: :admin}) | ||||
|       {:error, %Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   @spec update_invite(Invite.t(), attrs :: map(), User.t()) :: | ||||
|           {:ok, Invite.t()} | {:error, Changeset.t(Invite.t())} | ||||
|   def update_invite(invite, attrs, %User{role: :admin}), | ||||
|     do: invite |> Invite.update_changeset(attrs) |> Repo.update() | ||||
|  | ||||
|   @doc """ | ||||
|   Deletes a invite. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> delete_invite(invite, %User{id: 123, role: :admin}) | ||||
|       {:ok, %Invite{}} | ||||
|  | ||||
|       iex> delete_invite(invite, %User{id: 123, role: :admin}) | ||||
|       {:error, %Changeset{}} | ||||
|  | ||||
|   """ | ||||
|   @spec delete_invite(Invite.t(), User.t()) :: | ||||
|           {:ok, Invite.t()} | {:error, Changeset.t(Invite.t())} | ||||
|   def delete_invite(invite, %User{role: :admin}), do: invite |> Repo.delete() | ||||
|  | ||||
|   @doc """ | ||||
|   Deletes a invite. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> delete_invite(invite, %User{id: 123, role: :admin}) | ||||
|       %Invite{} | ||||
|  | ||||
|   """ | ||||
|   @spec delete_invite!(Invite.t(), User.t()) :: Invite.t() | ||||
|   def delete_invite!(invite, %User{role: :admin}), do: invite |> Repo.delete!() | ||||
|  | ||||
|   @doc """ | ||||
|   Returns an `%Changeset{}` for tracking invite changes. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       iex> change_invite(invite) | ||||
|       %Changeset{data: %Invite{}} | ||||
|  | ||||
|   """ | ||||
|   @spec change_invite(Invite.t() | Invite.new_invite()) :: | ||||
|           Changeset.t(Invite.t() | Invite.new_invite()) | ||||
|   @spec change_invite(Invite.t() | Invite.new_invite(), attrs :: map()) :: | ||||
|           Changeset.t(Invite.t() | Invite.new_invite()) | ||||
|   def change_invite(invite, attrs \\ %{}), do: invite |> Invite.update_changeset(attrs) | ||||
| end | ||||
							
								
								
									
										63
									
								
								lib/memex/logger.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										63
									
								
								lib/memex/logger.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,63 @@ | ||||
| defmodule Memex.Logger do | ||||
|   @moduledoc """ | ||||
|   Custom logger for telemetry events | ||||
|  | ||||
|   Oban implementation taken from | ||||
|   https://hexdocs.pm/oban/Oban.html#module-reporting-errors | ||||
|   """ | ||||
|  | ||||
|   require Logger | ||||
|  | ||||
|   def handle_event([:oban, :job, :exception], measure, %{stacktrace: stacktrace} = meta, _config) do | ||||
|     data = | ||||
|       get_oban_job_data(meta, measure) | ||||
|       |> Map.put(:stacktrace, Exception.format_stacktrace(stacktrace)) | ||||
|       |> pretty_encode() | ||||
|  | ||||
|     Logger.error("#{meta.reason} #{data}") | ||||
|   end | ||||
|  | ||||
|   def handle_event([:oban, :job, :start], measure, meta, _config) do | ||||
|     data = get_oban_job_data(meta, measure) |> pretty_encode() | ||||
|     Logger.info("Started oban job: #{data}") | ||||
|   end | ||||
|  | ||||
|   def handle_event([:oban, :job, :stop], measure, meta, _config) do | ||||
|     data = get_oban_job_data(meta, measure) |> pretty_encode() | ||||
|     Logger.info("Finished oban job: #{data}") | ||||
|   end | ||||
|  | ||||
|   def handle_event([:oban, :job, unhandled_event], measure, meta, _config) do | ||||
|     data = | ||||
|       get_oban_job_data(meta, measure) | ||||
|       |> Map.put(:event, unhandled_event) | ||||
|       |> pretty_encode() | ||||
|  | ||||
|     Logger.warning("Unhandled oban job event: #{data}") | ||||
|   end | ||||
|  | ||||
|   def handle_event(unhandled_event, measure, meta, config) do | ||||
|     data = | ||||
|       pretty_encode(%{ | ||||
|         event: unhandled_event, | ||||
|         meta: meta, | ||||
|         measurements: measure, | ||||
|         config: config | ||||
|       }) | ||||
|  | ||||
|     Logger.warning("Unhandled telemetry event: #{data}") | ||||
|   end | ||||
|  | ||||
|   defp get_oban_job_data(%{job: job}, measure) do | ||||
|     %{ | ||||
|       job: job |> Map.take([:id, :args, :meta, :queue, :worker]), | ||||
|       measurements: measure | ||||
|     } | ||||
|   end | ||||
|  | ||||
|   defp pretty_encode(data) do | ||||
|     data | ||||
|     |> Jason.encode!() | ||||
|     |> Jason.Formatter.pretty_print() | ||||
|   end | ||||
| end | ||||
| @@ -228,4 +228,13 @@ defmodule Memex.Notes do | ||||
|   def change_note(%Note{} = note, attrs \\ %{}, user) do | ||||
|     note |> Note.update_changeset(attrs, user) | ||||
|   end | ||||
|  | ||||
|   @spec is_owner_or_admin?(Note.t(), User.t()) :: boolean() | ||||
|   def is_owner_or_admin?(%{user_id: user_id}, %{id: user_id}), do: true | ||||
|   def is_owner_or_admin?(_context, %{role: :admin}), do: true | ||||
|   def is_owner_or_admin?(_context, _other_user), do: false | ||||
|  | ||||
|   @spec is_owner?(Note.t(), User.t()) :: boolean() | ||||
|   def is_owner?(%{user_id: user_id}, %{id: user_id}), do: true | ||||
|   def is_owner?(_context, _other_user), do: false | ||||
| end | ||||
|   | ||||
| @@ -8,6 +8,7 @@ defmodule Memex.Notes.Note do | ||||
|   alias Ecto.{Changeset, UUID} | ||||
|   alias Memex.{Accounts.User, Repo} | ||||
|  | ||||
|   @derive {Phoenix.Param, key: :slug} | ||||
|   @derive {Jason.Encoder, | ||||
|            only: [ | ||||
|              :slug, | ||||
| @@ -26,7 +27,7 @@ defmodule Memex.Notes.Note do | ||||
|     field :tags_string, :string, virtual: true | ||||
|     field :visibility, Ecto.Enum, values: [:public, :private, :unlisted] | ||||
|  | ||||
|     belongs_to :user, User | ||||
|     field :user_id, :binary_id | ||||
|  | ||||
|     timestamps() | ||||
|   end | ||||
| @@ -37,7 +38,6 @@ defmodule Memex.Notes.Note do | ||||
|           tags: [String.t()] | nil, | ||||
|           tags_string: String.t() | nil, | ||||
|           visibility: :public | :private | :unlisted, | ||||
|           user: User.t() | Ecto.Association.NotLoaded.t(), | ||||
|           user_id: User.id(), | ||||
|           inserted_at: NaiveDateTime.t(), | ||||
|           updated_at: NaiveDateTime.t() | ||||
| @@ -78,11 +78,11 @@ defmodule Memex.Notes.Note do | ||||
|     changeset | ||||
|     |> put_change(:tags_string, changeset |> get_field(:tags) |> get_tags_string()) | ||||
|     |> cast(attrs, [:tags_string]) | ||||
|     |> validate_format(:tags_string, ~r/^[\p{L}\p{N}\-\,]+$/, | ||||
|     |> validate_format(:tags_string, ~r/^[\p{L}\p{N}\-\, ]+$/, | ||||
|       message: | ||||
|         dgettext( | ||||
|           "errors", | ||||
|           "invalid format: only numbers, letters and hyphen are accepted. tags must be comma-delimited" | ||||
|           "invalid format: only numbers, letters and hyphen are accepted. tags must be comma or space delimited" | ||||
|         ) | ||||
|     ) | ||||
|     |> cast_tags() | ||||
| @@ -97,9 +97,9 @@ defmodule Memex.Notes.Note do | ||||
|  | ||||
|   defp process_tags(tags_string) when tags_string |> is_binary() do | ||||
|     tags_string | ||||
|     |> String.split(",", trim: true) | ||||
|     |> String.split([",", " "], trim: true) | ||||
|     |> Enum.map(fn str -> str |> String.trim() end) | ||||
|     |> Enum.reject(fn str -> str |> is_nil() end) | ||||
|     |> Enum.reject(fn str -> str in [nil, ""] end) | ||||
|     |> Enum.sort() | ||||
|   end | ||||
|  | ||||
|   | ||||
| @@ -230,4 +230,13 @@ defmodule Memex.Pipelines do | ||||
|   def change_pipeline(%Pipeline{} = pipeline, attrs \\ %{}, user) do | ||||
|     pipeline |> Pipeline.update_changeset(attrs, user) | ||||
|   end | ||||
|  | ||||
|   @spec is_owner_or_admin?(Pipeline.t(), User.t()) :: boolean() | ||||
|   def is_owner_or_admin?(%{user_id: user_id}, %{id: user_id}), do: true | ||||
|   def is_owner_or_admin?(_context, %{role: :admin}), do: true | ||||
|   def is_owner_or_admin?(_context, _other_user), do: false | ||||
|  | ||||
|   @spec is_owner?(Pipeline.t(), User.t()) :: boolean() | ||||
|   def is_owner?(%{user_id: user_id}, %{id: user_id}), do: true | ||||
|   def is_owner?(_context, _other_user), do: false | ||||
| end | ||||
|   | ||||
| @@ -8,6 +8,7 @@ defmodule Memex.Pipelines.Pipeline do | ||||
|   alias Ecto.{Changeset, UUID} | ||||
|   alias Memex.{Accounts.User, Pipelines.Steps.Step, Repo} | ||||
|  | ||||
|   @derive {Phoenix.Param, key: :slug} | ||||
|   @derive {Jason.Encoder, | ||||
|            only: [ | ||||
|              :slug, | ||||
| @@ -27,7 +28,7 @@ defmodule Memex.Pipelines.Pipeline do | ||||
|     field :tags_string, :string, virtual: true | ||||
|     field :visibility, Ecto.Enum, values: [:public, :private, :unlisted] | ||||
|  | ||||
|     belongs_to :user, User | ||||
|     field :user_id, :binary_id | ||||
|  | ||||
|     has_many :steps, Step, preload_order: [asc: :position] | ||||
|  | ||||
| @@ -40,7 +41,6 @@ defmodule Memex.Pipelines.Pipeline do | ||||
|           tags: [String.t()] | nil, | ||||
|           tags_string: String.t() | nil, | ||||
|           visibility: :public | :private | :unlisted, | ||||
|           user: User.t() | Ecto.Association.NotLoaded.t(), | ||||
|           user_id: User.id(), | ||||
|           inserted_at: NaiveDateTime.t(), | ||||
|           updated_at: NaiveDateTime.t() | ||||
| @@ -81,11 +81,11 @@ defmodule Memex.Pipelines.Pipeline do | ||||
|     changeset | ||||
|     |> put_change(:tags_string, changeset |> get_field(:tags) |> get_tags_string()) | ||||
|     |> cast(attrs, [:tags_string]) | ||||
|     |> validate_format(:tags_string, ~r/^[\p{L}\p{N}\-\,]+$/, | ||||
|     |> validate_format(:tags_string, ~r/^[\p{L}\p{N}\-\, ]+$/, | ||||
|       message: | ||||
|         dgettext( | ||||
|           "errors", | ||||
|           "invalid format: only numbers, letters and hyphen are accepted. tags must be comma-delimited" | ||||
|           "invalid format: only numbers, letters and hyphen are accepted. tags must be comma or space delimited" | ||||
|         ) | ||||
|     ) | ||||
|     |> cast_tags() | ||||
| @@ -100,9 +100,9 @@ defmodule Memex.Pipelines.Pipeline do | ||||
|  | ||||
|   defp process_tags(tags_string) when tags_string |> is_binary() do | ||||
|     tags_string | ||||
|     |> String.split(",", trim: true) | ||||
|     |> String.split([",", " "], trim: true) | ||||
|     |> Enum.map(fn str -> str |> String.trim() end) | ||||
|     |> Enum.reject(fn str -> str |> is_nil() end) | ||||
|     |> Enum.reject(fn str -> str in [nil, ""] end) | ||||
|     |> Enum.sort() | ||||
|   end | ||||
|  | ||||
|   | ||||
| @@ -23,7 +23,7 @@ defmodule Memex.Pipelines.Steps.Step do | ||||
|     field :position, :integer | ||||
|  | ||||
|     belongs_to :pipeline, Pipeline | ||||
|     belongs_to :user, User | ||||
|     field :user_id, :binary_id | ||||
|  | ||||
|     timestamps() | ||||
|   end | ||||
| @@ -34,7 +34,6 @@ defmodule Memex.Pipelines.Steps.Step do | ||||
|           position: non_neg_integer(), | ||||
|           pipeline: Pipeline.t() | Ecto.Association.NotLoaded.t(), | ||||
|           pipeline_id: Pipeline.id(), | ||||
|           user: User.t() | Ecto.Association.NotLoaded.t(), | ||||
|           user_id: User.id(), | ||||
|           inserted_at: NaiveDateTime.t(), | ||||
|           updated_at: NaiveDateTime.t() | ||||
| @@ -51,7 +50,7 @@ defmodule Memex.Pipelines.Steps.Step do | ||||
|     %__MODULE__{} | ||||
|     |> cast(attrs, [:title, :content]) | ||||
|     |> change(pipeline_id: pipeline_id, user_id: user_id, position: position) | ||||
|     |> validate_required([:title, :content, :user_id, :position]) | ||||
|     |> validate_required([:title, :user_id, :position]) | ||||
|   end | ||||
|  | ||||
|   @spec update_changeset(t(), attrs :: map(), User.t()) :: | ||||
| @@ -63,7 +62,7 @@ defmodule Memex.Pipelines.Steps.Step do | ||||
|       ) do | ||||
|     step | ||||
|     |> cast(attrs, [:title, :content]) | ||||
|     |> validate_required([:title, :content, :user_id, :position]) | ||||
|     |> validate_required([:title, :user_id, :position]) | ||||
|   end | ||||
|  | ||||
|   @spec position_changeset(t(), position :: non_neg_integer(), User.t()) :: changeset() | ||||
| @@ -74,6 +73,6 @@ defmodule Memex.Pipelines.Steps.Step do | ||||
|       ) do | ||||
|     step | ||||
|     |> change(position: position) | ||||
|     |> validate_required([:title, :content, :user_id, :position]) | ||||
|     |> validate_required([:title, :user_id, :position]) | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -7,7 +7,9 @@ defmodule Memex.Release do | ||||
|  | ||||
|   def rollback(repo, version) do | ||||
|     load_app() | ||||
|     {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) | ||||
|  | ||||
|     {:ok, _fun_return, _apps} = | ||||
|       Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :down, to: version)) | ||||
|   end | ||||
|  | ||||
|   defp load_app do | ||||
| @@ -18,7 +20,8 @@ defmodule Memex.Release do | ||||
|     load_app() | ||||
|  | ||||
|     for repo <- Application.fetch_env!(@app, :ecto_repos) do | ||||
|       {:ok, _, _} = Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) | ||||
|       {:ok, _fun_return, _apps} = | ||||
|         Ecto.Migrator.with_repo(repo, &Ecto.Migrator.run(&1, :up, all: true)) | ||||
|     end | ||||
|   end | ||||
| end | ||||
|   | ||||
| @@ -6,17 +6,20 @@ defmodule Memex.Repo.Migrator do | ||||
|   use GenServer | ||||
|   require Logger | ||||
|  | ||||
|   def start_link(_) do | ||||
|   def start_link(_opts) do | ||||
|     GenServer.start_link(__MODULE__, [], []) | ||||
|   end | ||||
|  | ||||
|   def init(_) do | ||||
|     migrate!() | ||||
|     {:ok, nil} | ||||
|   def init(_opts) do | ||||
|     {:ok, if(automigrate_enabled?(), do: migrate!())} | ||||
|   end | ||||
|  | ||||
|   def migrate! do | ||||
|     path = Application.app_dir(:memex, "priv/repo/migrations") | ||||
|     Ecto.Migrator.run(Memex.Repo, path, :up, all: true) | ||||
|   end | ||||
|  | ||||
|   defp automigrate_enabled? do | ||||
|     Application.get_env(:memex, Memex.Application, automigrate: false)[:automigrate] | ||||
|   end | ||||
| end | ||||
|   | ||||
							
								
								
									
										138
									
								
								lib/memex_web.ex
									
									
									
									
									
								
							
							
						
						
									
										138
									
								
								lib/memex_web.ex
									
									
									
									
									
								
							| @@ -1,78 +1,29 @@ | ||||
| defmodule MemexWeb do | ||||
|   @moduledoc """ | ||||
|   The entrypoint for defining your web interface, such | ||||
|   as controllers, views, channels and so on. | ||||
|   as controllers, components, channels, and so on. | ||||
|  | ||||
|   This can be used in your application as: | ||||
|  | ||||
|       use MemexWeb, :controller | ||||
|       use MemexWeb, :view | ||||
|       use MemexWeb, :html | ||||
|  | ||||
|   The definitions below will be executed for every view, | ||||
|   controller, etc, so keep them short and clean, focused | ||||
|   The definitions below will be executed for every controller, | ||||
|   component, etc, so keep them short and clean, focused | ||||
|   on imports, uses and aliases. | ||||
|  | ||||
|   Do NOT define functions inside the quoted expressions | ||||
|   below. Instead, define any helper function in modules | ||||
|   and import those modules here. | ||||
|   below. Instead, define additional modules and import | ||||
|   those modules here. | ||||
|   """ | ||||
|  | ||||
|   def controller do | ||||
|     quote do | ||||
|       use Phoenix.Controller, namespace: MemexWeb | ||||
|  | ||||
|       import Plug.Conn | ||||
|       import MemexWeb.Gettext | ||||
|       alias MemexWeb.Endpoint | ||||
|       alias MemexWeb.Router.Helpers, as: Routes | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def view do | ||||
|     quote do | ||||
|       use Phoenix.View, | ||||
|         root: "lib/memex_web/templates", | ||||
|         namespace: MemexWeb | ||||
|  | ||||
|       # Import convenience functions from controllers | ||||
|       import Phoenix.Controller, | ||||
|         only: [get_flash: 1, get_flash: 2, view_module: 1, view_template: 1] | ||||
|  | ||||
|       # Include shared imports and aliases for views | ||||
|       unquote(view_helpers()) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def live_view do | ||||
|     quote do | ||||
|       use Phoenix.LiveView, | ||||
|         layout: {MemexWeb.LayoutView, "live.html"} | ||||
|  | ||||
|       on_mount MemexWeb.InitAssigns | ||||
|       unquote(view_helpers()) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def live_component do | ||||
|     quote do | ||||
|       use Phoenix.LiveComponent | ||||
|  | ||||
|       unquote(view_helpers()) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def component do | ||||
|     quote do | ||||
|       use Phoenix.Component | ||||
|  | ||||
|       unquote(view_helpers()) | ||||
|     end | ||||
|   end | ||||
|   def static_paths, do: ~w(css js fonts images favicon.ico robots.txt) | ||||
|  | ||||
|   def router do | ||||
|     quote do | ||||
|       use Phoenix.Router | ||||
|       use Phoenix.Router, helpers: false | ||||
|  | ||||
|       # Import common connection and controller functions to use in pipelines | ||||
|       import Plug.Conn | ||||
|       import Phoenix.Controller | ||||
|       import Phoenix.LiveView.Router | ||||
| @@ -82,25 +33,76 @@ defmodule MemexWeb do | ||||
|   def channel do | ||||
|     quote do | ||||
|       use Phoenix.Channel | ||||
|       import MemexWeb.Gettext | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   defp view_helpers do | ||||
|   def controller do | ||||
|     quote do | ||||
|       # Use all HTML functionality (forms, tags, etc) | ||||
|       use Phoenix.Controller, | ||||
|         formats: [:html, :json], | ||||
|         layouts: [html: MemexWeb.Layouts] | ||||
|  | ||||
|       # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse | ||||
|       import Plug.Conn | ||||
|       import MemexWeb.Gettext | ||||
|  | ||||
|       unquote(verified_routes()) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def live_view do | ||||
|     quote do | ||||
|       use Phoenix.LiveView, | ||||
|         layout: {MemexWeb.Layouts, :app} | ||||
|  | ||||
|       unquote(html_helpers()) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def live_component do | ||||
|     quote do | ||||
|       use Phoenix.LiveComponent | ||||
|  | ||||
|       unquote(html_helpers()) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def html do | ||||
|     quote do | ||||
|       use Phoenix.Component | ||||
|  | ||||
|       # Import convenience functions from controllers | ||||
|       import Phoenix.Controller, | ||||
|         only: [get_csrf_token: 0, view_module: 1, view_template: 1] | ||||
|  | ||||
|       # Include general helpers for rendering HTML | ||||
|       unquote(html_helpers()) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   defp html_helpers do | ||||
|     quote do | ||||
|       # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse | ||||
|       use Phoenix.HTML | ||||
|  | ||||
|       # Import LiveView and .heex helpers (live_render, live_patch, <.form>, etc) | ||||
|       # credo:disable-for-next-line Credo.Check.Consistency.MultiAliasImportRequireUse | ||||
|       import Phoenix.Component | ||||
|       import MemexWeb.LiveHelpers | ||||
|       import MemexWeb.{ErrorHelpers, Gettext, CoreComponents, HTMLHelpers} | ||||
|  | ||||
|       # Import basic rendering functionality (render, render_layout, etc) | ||||
|       import Phoenix.View | ||||
|       # Shortcut for generating JS commands | ||||
|       alias Phoenix.LiveView.JS | ||||
|  | ||||
|       import MemexWeb.{ErrorHelpers, Gettext, LiveHelpers, ViewHelpers} | ||||
|       alias MemexWeb.Endpoint | ||||
|       alias MemexWeb.Router.Helpers, as: Routes | ||||
|       # Routes generation with the ~p sigil | ||||
|       unquote(verified_routes()) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def verified_routes do | ||||
|     quote do | ||||
|       use Phoenix.VerifiedRoutes, | ||||
|         endpoint: MemexWeb.Endpoint, | ||||
|         router: MemexWeb.Router, | ||||
|         statics: MemexWeb.static_paths() | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   | ||||
| @@ -1,44 +0,0 @@ | ||||
| defmodule MemexWeb.Components.ContextContent do | ||||
|   @moduledoc """ | ||||
|   Display the content for a context | ||||
|   """ | ||||
|   use MemexWeb, :component | ||||
|   alias Memex.Contexts.Context | ||||
|   alias Phoenix.HTML | ||||
|  | ||||
|   attr :context, Context, required: true | ||||
|  | ||||
|   def context_content(assigns) do | ||||
|     ~H""" | ||||
|     <div | ||||
|       id={"show-context-content-#{@context.id}"} | ||||
|       class="input input-primary h-128 min-h-128 inline-block whitespace-pre-wrap overflow-x-hidden overflow-y-auto" | ||||
|       phx-hook="MaintainAttrs" | ||||
|       phx-update="ignore" | ||||
|       readonly | ||||
|       phx-no-format | ||||
|     ><p class="inline"><%= add_links_to_content(@context.content) %></p></div> | ||||
|     """ | ||||
|   end | ||||
|  | ||||
|   defp add_links_to_content(content) do | ||||
|     Regex.replace( | ||||
|       ~r/\[\[([\p{L}\p{N}\-]+)\]\]/, | ||||
|       content, | ||||
|       fn _whole_match, slug -> | ||||
|         link = | ||||
|           HTML.Link.link( | ||||
|             "[[#{slug}]]", | ||||
|             to: Routes.note_show_path(Endpoint, :show, slug), | ||||
|             class: "link inline", | ||||
|             data: [qa: "context-note-#{slug}"] | ||||
|           ) | ||||
|           |> HTML.Safe.to_iodata() | ||||
|           |> IO.iodata_to_binary() | ||||
|  | ||||
|         "</p>#{link}<p class=\"inline\">" | ||||
|       end | ||||
|     ) | ||||
|     |> HTML.raw() | ||||
|   end | ||||
| end | ||||
| @@ -40,7 +40,7 @@ defmodule MemexWeb.Components.ContextsTableComponent do | ||||
|       if actions == [] or current_user |> is_nil() do | ||||
|         [] | ||||
|       else | ||||
|         [%{label: nil, key: :actions, sortable: false}] | ||||
|         [%{label: gettext("actions"), key: :actions, sortable: false}] | ||||
|       end | ||||
|  | ||||
|     columns = [ | ||||
| @@ -88,15 +88,9 @@ defmodule MemexWeb.Components.ContextsTableComponent do | ||||
|  | ||||
|   @spec get_value_for_key(atom(), Context.t(), additional_data :: map()) :: | ||||
|           any() | {any(), Rendered.t()} | ||||
|   defp get_value_for_key(:slug, %{slug: slug}, _additional_data) do | ||||
|     assigns = %{slug: slug} | ||||
|  | ||||
|   defp get_value_for_key(:slug, %{slug: slug} = assigns, _additional_data) do | ||||
|     slug_block = ~H""" | ||||
|     <.link | ||||
|       navigate={Routes.context_show_path(Endpoint, :show, @slug)} | ||||
|       class="link" | ||||
|       data-qa={"context-show-#{@slug}"} | ||||
|     > | ||||
|     <.link navigate={~p"/context/#{@slug}"} class="link"> | ||||
|       <%= @slug %> | ||||
|     </.link> | ||||
|     """ | ||||
| @@ -104,16 +98,12 @@ defmodule MemexWeb.Components.ContextsTableComponent do | ||||
|     {slug, slug_block} | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(:tags, %{tags: tags}, _additional_data) do | ||||
|     assigns = %{tags: tags} | ||||
|  | ||||
|   defp get_value_for_key(:tags, assigns, _additional_data) do | ||||
|     ~H""" | ||||
|     <div class="flex flex-wrap justify-center space-x-1"> | ||||
|       <%= for tag <- @tags do %> | ||||
|         <.link patch={Routes.context_index_path(Endpoint, :search, tag)} class="link"> | ||||
|           <%= tag %> | ||||
|         </.link> | ||||
|       <% end %> | ||||
|       <.link :for={tag <- @tags} patch={~p"/contexts/#{tag}"} class="link"> | ||||
|         <%= tag %> | ||||
|       </.link> | ||||
|     </div> | ||||
|     """ | ||||
|   end | ||||
|   | ||||
							
								
								
									
										255
									
								
								lib/memex_web/components/core_components.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										255
									
								
								lib/memex_web/components/core_components.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,255 @@ | ||||
| defmodule MemexWeb.CoreComponents do | ||||
|   @moduledoc """ | ||||
|   Provides core UI components. | ||||
|   """ | ||||
|   use Phoenix.Component | ||||
|   use MemexWeb, :verified_routes | ||||
|   import MemexWeb.{Gettext, HTMLHelpers} | ||||
|   alias Memex.{Accounts, Accounts.Invite, Accounts.User} | ||||
|   alias Memex.Contexts.Context | ||||
|   alias Memex.Notes.Note | ||||
|   alias Memex.Pipelines.{Pipeline, Steps.Step} | ||||
|   alias Phoenix.HTML | ||||
|   alias Phoenix.LiveView.JS | ||||
|  | ||||
|   embed_templates("core_components/*") | ||||
|  | ||||
|   attr :title_content, :string, default: nil | ||||
|   attr :current_user, User, default: nil | ||||
|  | ||||
|   def topbar(assigns) | ||||
|  | ||||
|   attr :return_to, :string, required: true | ||||
|   slot(:inner_block) | ||||
|  | ||||
|   @doc """ | ||||
|   Renders a live component inside a modal. | ||||
|  | ||||
|   The rendered modal receives a `:return_to` option to properly update | ||||
|   the URL when the modal is closed. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|       <.modal return_to={~p"/\#{<%= schema.plural %>}"}> | ||||
|         <.live_component | ||||
|           module={<%= inspect context.web_module %>.<%= inspect Module.concat(schema.web_namespace, schema.alias) %>Live.FormComponent} | ||||
|           id={@<%= schema.singular %>.id || :new} | ||||
|           title={@page_title} | ||||
|           action={@live_action} | ||||
|           return_to={~p"/\#{<%= schema.singular %>}"} | ||||
|           <%= schema.singular %>: @<%= schema.singular %> | ||||
|         /> | ||||
|       </.modal> | ||||
|   """ | ||||
|   def modal(assigns) | ||||
|  | ||||
|   defp hide_modal(js \\ %JS{}) do | ||||
|     js | ||||
|     |> JS.hide(to: "#modal", transition: "fade-out") | ||||
|     |> JS.hide(to: "#modal-bg", transition: "fade-out") | ||||
|     |> JS.hide(to: "#modal-content", transition: "fade-out-scale") | ||||
|   end | ||||
|  | ||||
|   attr :action, :string, required: true | ||||
|   attr :value, :boolean, required: true | ||||
|   attr :id, :string, default: nil | ||||
|   slot(:inner_block) | ||||
|  | ||||
|   @doc """ | ||||
|   A toggle button element that can be directed to a liveview or a | ||||
|   live_component's `handle_event/3`. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|   <.toggle_button action="my_liveview_action" value={@some_value}> | ||||
|     <span>Toggle me!</span> | ||||
|   </.toggle_button> | ||||
|   <.toggle_button action="my_live_component_action" target={@myself} value={@some_value}> | ||||
|     <span>Whatever you want</span> | ||||
|   </.toggle_button> | ||||
|   """ | ||||
|   def toggle_button(assigns) | ||||
|  | ||||
|   attr :user, User, required: true | ||||
|   slot(:inner_block, required: true) | ||||
|  | ||||
|   def user_card(assigns) | ||||
|  | ||||
|   attr :invite, Invite, required: true | ||||
|   attr :use_count, :integer, default: nil | ||||
|   attr :current_user, User, required: true | ||||
|   slot(:inner_block) | ||||
|   slot(:code_actions) | ||||
|  | ||||
|   def invite_card(assigns) | ||||
|  | ||||
|   attr :id, :string, required: true | ||||
|   attr :datetime, :any, required: true, doc: "A `DateTime` struct or nil" | ||||
|  | ||||
|   @doc """ | ||||
|   Phoenix.Component for a <time> element that renders the naivedatetime in the | ||||
|   user's local timezone | ||||
|   """ | ||||
|   def datetime(assigns) | ||||
|  | ||||
|   @spec cast_datetime(NaiveDateTime.t() | nil) :: String.t() | ||||
|   defp cast_datetime(%NaiveDateTime{} = datetime) do | ||||
|     datetime |> DateTime.from_naive!("Etc/UTC") |> DateTime.to_iso8601(:extended) | ||||
|   end | ||||
|  | ||||
|   defp cast_datetime(_datetime), do: "" | ||||
|  | ||||
|   attr :id, :string, required: true | ||||
|   attr :date, :any, required: true, doc: "A `Date` struct or nil" | ||||
|  | ||||
|   @doc """ | ||||
|   Phoenix.Component for a <date> element that renders the Date in the user's | ||||
|   local timezone | ||||
|   """ | ||||
|   def date(assigns) | ||||
|  | ||||
|   attr :content, :string, required: true | ||||
|   attr :filename, :string, default: "qrcode", doc: "filename without .png extension" | ||||
|   attr :image_class, :string, default: "w-64 h-max" | ||||
|   attr :width, :integer, default: 384, doc: "width of png to generate" | ||||
|  | ||||
|   @doc """ | ||||
|   Creates a downloadable QR Code element | ||||
|   """ | ||||
|   def qr_code(assigns) | ||||
|  | ||||
|   attr :note, Note, required: true | ||||
|  | ||||
|   def note_content(assigns) | ||||
|  | ||||
|   attr :context, Context, required: true | ||||
|  | ||||
|   def context_content(assigns) | ||||
|  | ||||
|   attr :step, Step, required: true | ||||
|  | ||||
|   def step_content(assigns) | ||||
|  | ||||
|   attr :pipeline, Pipeline, required: true | ||||
|  | ||||
|   def pipeline_content(assigns) | ||||
|  | ||||
|   defp display_links(record) do | ||||
|     record | ||||
|     |> get_content() | ||||
|     |> replace_hyperlinks(record) | ||||
|     |> replace_triple_links(record) | ||||
|     |> replace_double_links(record) | ||||
|     |> replace_single_links(record) | ||||
|     |> HTML.raw() | ||||
|   end | ||||
|  | ||||
|   defp get_content(%{content: content}), do: content |> get_text() | ||||
|   defp get_content(%{description: description}), do: description |> get_text() | ||||
|   defp get_content(_fallthrough), do: nil |> get_text() | ||||
|  | ||||
|   defp get_text(string) when is_binary(string), do: string | ||||
|   defp get_text(_fallthrough), do: "" | ||||
|  | ||||
|   # replaces hyperlinks like https://bubbletea.dev | ||||
|   # | ||||
|   # link regex from | ||||
|   # https://stackoverflow.com/questions/3809401/what-is-a-good-regular-expression-to-match-a-url | ||||
|   # and modified with additional schemes from | ||||
|   # https://www.iana.org/assignments/uri-schemes/uri-schemes.xhtml | ||||
|   defp replace_hyperlinks(content, _record) do | ||||
|     Regex.replace( | ||||
|       ~r<((file|git|https?|ipfs|ipns|irc|jabber|magnet|mailto|mumble|tel|udp|xmpp):\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*))>, | ||||
|       content, | ||||
|       fn _whole_match, link -> | ||||
|         link = | ||||
|           HTML.Link.link( | ||||
|             link, | ||||
|             to: link, | ||||
|             class: "link inline", | ||||
|             target: "_blank", | ||||
|             rel: "noopener noreferrer" | ||||
|           ) | ||||
|           |> HTML.Safe.to_iodata() | ||||
|           |> IO.iodata_to_binary() | ||||
|  | ||||
|         "</p>#{link}<p class=\"inline\">" | ||||
|       end | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   # replaces triple links like [[[slug-title]]] | ||||
|   defp replace_triple_links(content, _record) do | ||||
|     Regex.replace( | ||||
|       ~r/(^|[^\[])\[\[\[([\p{L}\p{N}\-]+)\]\]\]($|[^\]])/, | ||||
|       content, | ||||
|       fn _whole_match, prefix, slug, suffix -> | ||||
|         link = | ||||
|           HTML.Link.link( | ||||
|             "[[[#{slug}]]]", | ||||
|             to: ~p"/note/#{slug}", | ||||
|             class: "link inline" | ||||
|           ) | ||||
|           |> HTML.Safe.to_iodata() | ||||
|           |> IO.iodata_to_binary() | ||||
|  | ||||
|         "#{prefix}</p>#{link}<p class=\"inline\">#{suffix}" | ||||
|       end | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   # replaces double links like [[slug-title]] | ||||
|   defp replace_double_links(content, record) do | ||||
|     Regex.replace( | ||||
|       ~r/(^|[^\[])\[\[([\p{L}\p{N}\-]+)\]\]($|[^\]])/, | ||||
|       content, | ||||
|       fn _whole_match, prefix, slug, suffix -> | ||||
|         target = | ||||
|           case record do | ||||
|             %Pipeline{} -> ~p"/context/#{slug}" | ||||
|             %Step{} -> ~p"/context/#{slug}" | ||||
|             _context -> ~p"/note/#{slug}" | ||||
|           end | ||||
|  | ||||
|         link = | ||||
|           HTML.Link.link( | ||||
|             "[[#{slug}]]", | ||||
|             to: target, | ||||
|             class: "link inline" | ||||
|           ) | ||||
|           |> HTML.Safe.to_iodata() | ||||
|           |> IO.iodata_to_binary() | ||||
|  | ||||
|         "#{prefix}</p>#{link}<p class=\"inline\">#{suffix}" | ||||
|       end | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   # replaces single links like [slug-title] | ||||
|   defp replace_single_links(content, record) do | ||||
|     Regex.replace( | ||||
|       ~r/(^|[^\[])\[([\p{L}\p{N}\-]+)\]($|[^\]])/, | ||||
|       content, | ||||
|       fn _whole_match, prefix, slug, suffix -> | ||||
|         target = | ||||
|           case record do | ||||
|             %Pipeline{} -> ~p"/pipeline/#{slug}" | ||||
|             %Step{} -> ~p"/pipeline/#{slug}" | ||||
|             %Context{} -> ~p"/context/#{slug}" | ||||
|             _note -> ~p"/note/#{slug}" | ||||
|           end | ||||
|  | ||||
|         link = | ||||
|           HTML.Link.link( | ||||
|             "[#{slug}]", | ||||
|             to: target, | ||||
|             class: "link inline" | ||||
|           ) | ||||
|           |> HTML.Safe.to_iodata() | ||||
|           |> IO.iodata_to_binary() | ||||
|  | ||||
|         "#{prefix}</p>#{link}<p class=\"inline\">#{suffix}" | ||||
|       end | ||||
|     ) | ||||
|   end | ||||
| end | ||||
| @@ -0,0 +1,7 @@ | ||||
| <div | ||||
|   id={"show-context-content-#{@context.id}"} | ||||
|   class="input input-primary h-128 min-h-128 inline-block whitespace-pre-wrap overflow-x-hidden overflow-y-auto resize-y" | ||||
|   phx-update="ignore" | ||||
|   readonly | ||||
|   phx-no-format | ||||
| ><p class="inline"><%= display_links(@context) %></p></div> | ||||
							
								
								
									
										3
									
								
								lib/memex_web/components/core_components/date.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								lib/memex_web/components/core_components/date.html.heex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | ||||
| <time :if={@date} id={@id} datetime={Date.to_iso8601(@date, :extended)} phx-hook="Date"> | ||||
|   <%= Date.to_iso8601(@date, :extended) %> | ||||
| </time> | ||||
| @@ -0,0 +1,3 @@ | ||||
| <time :if={@datetime} id={@id} datetime={cast_datetime(@datetime)} phx-hook="DateTime"> | ||||
|   <%= cast_datetime(@datetime) %> | ||||
| </time> | ||||
| @@ -0,0 +1,48 @@ | ||||
| <div class="px-8 py-4 flex flex-col justify-center items-center space-y-4 | ||||
|   bg-primary-900 | ||||
|   border border-gray-400 rounded-lg shadow-lg hover:shadow-md | ||||
|   transition-all duration-300 ease-in-out"> | ||||
|   <h1 class="title text-xl"> | ||||
|     <%= @invite.name %> | ||||
|   </h1> | ||||
|  | ||||
|   <%= if @invite.disabled_at |> is_nil() do %> | ||||
|     <h2 class="title text-md"> | ||||
|       <%= if @invite.uses_left do %> | ||||
|         <%= gettext( | ||||
|           "uses left: %{uses_left_count}", | ||||
|           uses_left_count: @invite.uses_left | ||||
|         ) %> | ||||
|       <% else %> | ||||
|         <%= gettext("uses left: unlimited") %> | ||||
|       <% end %> | ||||
|     </h2> | ||||
|   <% else %> | ||||
|     <h2 class="title text-md"> | ||||
|       <%= gettext("invite disabled") %> | ||||
|     </h2> | ||||
|   <% end %> | ||||
|  | ||||
|   <.qr_code | ||||
|     content={url(MemexWeb.Endpoint, ~p"/users/register?invite=#{@invite.token}")} | ||||
|     filename={@invite.name} | ||||
|   /> | ||||
|  | ||||
|   <h2 :if={@use_count && @use_count != 0} class="title text-md"> | ||||
|     <%= gettext("uses: %{uses_count}", uses_count: @use_count) %> | ||||
|   </h2> | ||||
|  | ||||
|   <div class="flex flex-row flex-wrap justify-center items-center"> | ||||
|     <code | ||||
|       id={"code-#{@invite.id}"} | ||||
|       class="mx-2 my-1 text-xs px-4 py-2 rounded-lg text-center break-all | ||||
|         text-primary-400 bg-primary-800" | ||||
|       phx-no-format | ||||
|     ><%= url(MemexWeb.Endpoint, ~p"/users/register?invite=#{@invite.token}") %></code> | ||||
|     <%= if @code_actions, do: render_slot(@code_actions) %> | ||||
|   </div> | ||||
|  | ||||
|   <div :if={@inner_block} class="flex space-x-4 justify-center items-center"> | ||||
|     <%= render_slot(@inner_block) %> | ||||
|   </div> | ||||
| </div> | ||||
							
								
								
									
										43
									
								
								lib/memex_web/components/core_components/modal.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								lib/memex_web/components/core_components/modal.html.heex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,43 @@ | ||||
| <.link | ||||
|   id="modal-bg" | ||||
|   patch={@return_to} | ||||
|   class="fade-in fixed z-10 left-0 top-0 | ||||
|     w-full h-full overflow-hidden | ||||
|     p-8 flex flex-col justify-center items-center cursor-auto" | ||||
|   style="background-color: rgba(0,0,0,0.4);" | ||||
|   phx-remove={hide_modal()} | ||||
|   aria-label={gettext("close modal")} | ||||
| > | ||||
|   <span class="hidden"></span> | ||||
| </.link> | ||||
|  | ||||
| <div | ||||
|   id="modal" | ||||
|   class="fixed z-10 left-0 top-0 pointer-events-none | ||||
|     w-full h-full overflow-hidden | ||||
|     p-4 sm:p-8 flex flex-col justify-center items-center" | ||||
| > | ||||
|   <div | ||||
|     id="modal-content" | ||||
|     class="fade-in-scale max-w-3xl max-h-3xl relative w-full | ||||
|       pointer-events-auto overflow-hidden | ||||
|       px-8 py-4 sm:py-8 flex flex-col justify-start items-stretch | ||||
|       bg-primary-800 text-primary-400 border-primary-900 border-2 rounded-lg" | ||||
|   > | ||||
|     <.link | ||||
|       patch={@return_to} | ||||
|       id="close" | ||||
|       class="absolute top-8 right-10 | ||||
|         text-gray-500 hover:text-gray-800 | ||||
|         transition-all duration-500 ease-in-out" | ||||
|       phx-remove={hide_modal()} | ||||
|       aria-label={gettext("close modal")} | ||||
|     > | ||||
|       <i class="fa-fw fa-lg fas fa-times"></i> | ||||
|     </.link> | ||||
|  | ||||
|     <div class="overflow-x-hidden overflow-y-auto w-full p-8 flex flex-col space-y-4 justify-start items-stretch"> | ||||
|       <%= render_slot(@inner_block) %> | ||||
|     </div> | ||||
|   </div> | ||||
| </div> | ||||
| @@ -0,0 +1,7 @@ | ||||
| <div | ||||
|   id={"show-note-content-#{@note.id}"} | ||||
|   class="input input-primary h-128 min-h-128 inline-block whitespace-pre-wrap overflow-x-hidden overflow-y-auto resize-y" | ||||
|   phx-update="ignore" | ||||
|   readonly | ||||
|   phx-no-format | ||||
| ><p class="inline"><%= display_links(@note) %></p></div> | ||||
| @@ -0,0 +1,7 @@ | ||||
| <div | ||||
|   id={"show-pipeline-description-#{@pipeline.id}"} | ||||
|   class="input input-primary h-32 min-h-32 inline-block whitespace-pre-wrap overflow-x-hidden overflow-y-auto resize-y" | ||||
|   phx-update="ignore" | ||||
|   readonly | ||||
|   phx-no-format | ||||
| ><p class="inline"><%= display_links(@pipeline) %></p></div> | ||||
| @@ -0,0 +1,3 @@ | ||||
| <a href={qr_code_image(@content)} download={@filename <> ".png"}> | ||||
|   <img class={@image_class} alt={@filename} src={qr_code_image(@content)} /> | ||||
| </a> | ||||
| @@ -0,0 +1,7 @@ | ||||
| <div | ||||
|   id={"show-step-content-#{@step.id}"} | ||||
|   class="input input-primary h-32 min-h-32 inline-block whitespace-pre-wrap overflow-x-hidden overflow-y-auto resize-y" | ||||
|   phx-update="ignore" | ||||
|   readonly | ||||
|   phx-no-format | ||||
| ><p class="inline"><%= display_links(@step) %></p></div> | ||||
| @@ -0,0 +1,30 @@ | ||||
| <label for={@id || @action} class="relative inline-flex items-center cursor-pointer"> | ||||
|   <input | ||||
|     id={@id || @action} | ||||
|     type="checkbox" | ||||
|     value={@value} | ||||
|     checked={@value} | ||||
|     class="sr-only peer" | ||||
|     aria-labelledby={"#{@id || @action}-label"} | ||||
|     { | ||||
|       if assigns |> Map.has_key?(:target), | ||||
|         do: %{"phx-click": @action, "phx-value-value": @value, "phx-target": @target}, | ||||
|         else: %{"phx-click": @action, "phx-value-value": @value} | ||||
|     } | ||||
|   /> | ||||
|   <div class="w-11 h-6 bg-gray-300 rounded-full peer | ||||
|     peer-focus:ring-4 peer-focus:ring-teal-300 dark:peer-focus:ring-teal-800 | ||||
|     peer-checked:bg-gray-600 | ||||
|     peer-checked:after:translate-x-full peer-checked:after:border-white | ||||
|     after:content-[''] after:absolute after:top-1 after:left-[2px] after:bg-white after:border-gray-300 | ||||
|     after:border after:rounded-full after:h-5 after:w-5 | ||||
|     after:transition-all after:duration-250 after:ease-in-out | ||||
|     transition-colors duration-250 ease-in-out"> | ||||
|   </div> | ||||
|   <span | ||||
|     id={"#{@id || @action}-label"} | ||||
|     class="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300 whitespace-nowrap" | ||||
|   > | ||||
|     <%= render_slot(@inner_block) %> | ||||
|   </span> | ||||
| </label> | ||||
							
								
								
									
										91
									
								
								lib/memex_web/components/core_components/topbar.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								lib/memex_web/components/core_components/topbar.html.heex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,91 @@ | ||||
| <nav role="navigation" class="mb-8 px-8 py-4 w-full bg-primary-900 text-primary-400"> | ||||
|   <div class="flex flex-col sm:flex-row justify-between items-center"> | ||||
|     <div class="mb-4 sm:mb-0 sm:mr-8 flex flex-row justify-start items-center space-x-2"> | ||||
|       <.link navigate={~p"/"} class="mx-2 my-1 leading-5 text-xl text-primary-400 hover:underline"> | ||||
|         <%= gettext("memEx") %> | ||||
|       </.link> | ||||
|  | ||||
|       <%= if @title_content do %> | ||||
|         <span class="mx-2 my-1"> | ||||
|           | | ||||
|         </span> | ||||
|         <%= @title_content %> | ||||
|       <% end %> | ||||
|     </div> | ||||
|  | ||||
|     <hr class="mb-2 sm:hidden hr-light" /> | ||||
|  | ||||
|     <ul class="flex flex-row flex-wrap justify-center items-center | ||||
|       text-lg text-primary-400 text-ellipsis"> | ||||
|       <li class="mx-2 my-1"> | ||||
|         <.link navigate={~p"/notes"} class="text-primary-400 hover:underline truncate"> | ||||
|           <%= gettext("notes") %> | ||||
|         </.link> | ||||
|       </li> | ||||
|  | ||||
|       <li class="mx-2 my-1"> | ||||
|         <.link navigate={~p"/contexts"} class="text-primary-400 hover:underline truncate"> | ||||
|           <%= gettext("contexts") %> | ||||
|         </.link> | ||||
|       </li> | ||||
|  | ||||
|       <li class="mx-2 my-1"> | ||||
|         <.link navigate={~p"/pipelines"} class="text-primary-400 hover:underline truncate"> | ||||
|           <%= gettext("pipelines") %> | ||||
|         </.link> | ||||
|       </li> | ||||
|  | ||||
|       <li class="mx-2 my-1 border-left border border-primary-700"></li> | ||||
|  | ||||
|       <%= if @current_user do %> | ||||
|         <li :if={@current_user |> Accounts.is_already_admin?()} class="mx-2 my-1"> | ||||
|           <.link navigate={~p"/invites"} class="text-primary-400 hover:underline"> | ||||
|             <%= gettext("invites") %> | ||||
|           </.link> | ||||
|         </li> | ||||
|  | ||||
|         <li class="mx-2 my-1"> | ||||
|           <.link navigate={~p"/users/settings"} class="text-primary-400 hover:underline truncate"> | ||||
|             <%= @current_user.email %> | ||||
|           </.link> | ||||
|         </li> | ||||
|         <li class="mx-2 my-1"> | ||||
|           <.link | ||||
|             href={~p"/users/log_out"} | ||||
|             method="delete" | ||||
|             data-confirm={dgettext("prompts", "are you sure you want to log out?")} | ||||
|             aria-label={gettext("log out")} | ||||
|           > | ||||
|             <i class="fas fa-sign-out-alt"></i> | ||||
|           </.link> | ||||
|         </li> | ||||
|         <li | ||||
|           :if={ | ||||
|             @current_user.role == :admin and function_exported?(Routes, :live_dashboard_path, 2) | ||||
|           } | ||||
|           class="mx-2 my-1" | ||||
|         > | ||||
|           <.link | ||||
|             navigate={~p"/dashboard"} | ||||
|             class="text-primary-400 hover:underline" | ||||
|             aria-label={gettext("live dashboard")} | ||||
|           > | ||||
|             <i class="fas fa-gauge"></i> | ||||
|           </.link> | ||||
|         </li> | ||||
|       <% else %> | ||||
|         <li :if={Accounts.allow_registration?()} class="mx-2 my-1"> | ||||
|           <.link href={~p"/users/register"} class="text-primary-400 hover:underline truncate"> | ||||
|             <%= dgettext("actions", "register") %> | ||||
|           </.link> | ||||
|         </li> | ||||
|  | ||||
|         <li class="mx-2 my-1"> | ||||
|           <.link href={~p"/users/log_in"} class="text-primary-400 hover:underline truncate"> | ||||
|             <%= dgettext("actions", "log in") %> | ||||
|           </.link> | ||||
|         </li> | ||||
|       <% end %> | ||||
|     </ul> | ||||
|   </div> | ||||
| </nav> | ||||
							
								
								
									
										37
									
								
								lib/memex_web/components/core_components/user_card.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								lib/memex_web/components/core_components/user_card.html.heex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | ||||
| <div | ||||
|   id={"user-#{@user.id}"} | ||||
|   class="px-8 py-4 flex flex-col justify-center items-center text-center | ||||
|     bg-primary-900 | ||||
|     border border-gray-400 rounded-lg shadow-lg hover:shadow-md | ||||
|     transition-all duration-300 ease-in-out" | ||||
| > | ||||
|   <h1 class="px-4 py-2 rounded-lg title text-xl break-all"> | ||||
|     <%= @user.email %> | ||||
|   </h1> | ||||
|  | ||||
|   <h3 class="px-4 py-2 rounded-lg title text-lg"> | ||||
|     <p> | ||||
|       <%= if @user.confirmed_at do %> | ||||
|         <%= gettext( | ||||
|           "user confirmed on%{confirmed_datetime}", | ||||
|           confirmed_datetime: "" | ||||
|         ) %> | ||||
|         <.datetime id={"#{@user.id}-confirmed-at"} datetime={@user.confirmed_at} /> | ||||
|       <% else %> | ||||
|         <%= gettext("email unconfirmed") %> | ||||
|       <% end %> | ||||
|     </p> | ||||
|  | ||||
|     <p> | ||||
|       <%= gettext( | ||||
|         "user registered on%{registered_datetime}", | ||||
|         registered_datetime: "" | ||||
|       ) %> | ||||
|       <.datetime id={"#{@user.id}-inserted-at"} datetime={@user.inserted_at} /> | ||||
|     </p> | ||||
|   </h3> | ||||
|  | ||||
|   <div :if={@inner_block} class="px-4 py-2 flex space-x-4 justify-center items-center"> | ||||
|     <%= render_slot(@inner_block) %> | ||||
|   </div> | ||||
| </div> | ||||
| @@ -1,58 +0,0 @@ | ||||
| defmodule MemexWeb.Components.InviteCard do | ||||
|   @moduledoc """ | ||||
|   Display card for an invite | ||||
|   """ | ||||
|  | ||||
|   use MemexWeb, :component | ||||
|  | ||||
|   def invite_card(assigns) do | ||||
|     assigns = assigns |> assign_new(:code_actions, fn -> [] end) | ||||
|  | ||||
|     ~H""" | ||||
|     <div class="mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center space-y-4 | ||||
|       border border-gray-400 rounded-lg shadow-lg hover:shadow-md | ||||
|       transition-all duration-300 ease-in-out"> | ||||
|       <h1 class="title text-xl"> | ||||
|         <%= @invite.name %> | ||||
|       </h1> | ||||
|  | ||||
|       <%= if @invite.disabled_at |> is_nil() do %> | ||||
|         <h2 class="title text-md"> | ||||
|           <%= if @invite.uses_left do %> | ||||
|             <%= gettext( | ||||
|               "uses left: %{uses_left}", | ||||
|               uses_left: @invite.uses_left | ||||
|             ) %> | ||||
|           <% else %> | ||||
|             <%= gettext("uses left: unlimited") %> | ||||
|           <% end %> | ||||
|         </h2> | ||||
|       <% else %> | ||||
|         <h2 class="title text-md"> | ||||
|           <%= gettext("invite disabled") %> | ||||
|         </h2> | ||||
|       <% end %> | ||||
|  | ||||
|       <.qr_code | ||||
|         content={Routes.user_registration_url(Endpoint, :new, invite: @invite.token)} | ||||
|         filename={@invite.name} | ||||
|       /> | ||||
|  | ||||
|       <div class="flex flex-row flex-wrap justify-center items-center"> | ||||
|         <code | ||||
|           id={"code-#{@invite.id}"} | ||||
|           class="mx-2 my-1 text-xs px-4 py-2 rounded-lg text-center break-all text-gray-100 bg-primary-800" | ||||
|           phx-no-format | ||||
|         ><%= Routes.user_registration_url(Endpoint, :new, invite: @invite.token) %></code> | ||||
|         <%= render_slot(@code_actions) %> | ||||
|       </div> | ||||
|  | ||||
|       <%= if @inner_block do %> | ||||
|         <div class="flex space-x-4 justify-center items-center"> | ||||
|           <%= render_slot(@inner_block) %> | ||||
|         </div> | ||||
|       <% end %> | ||||
|     </div> | ||||
|     """ | ||||
|   end | ||||
| end | ||||
							
								
								
									
										17
									
								
								lib/memex_web/components/layouts.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								lib/memex_web/components/layouts.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| defmodule MemexWeb.Layouts do | ||||
|   @moduledoc """ | ||||
|   The root layouts for the entire application | ||||
|   """ | ||||
|  | ||||
|   use MemexWeb, :html | ||||
|  | ||||
|   embed_templates "layouts/*" | ||||
|  | ||||
|   def get_title(%{assigns: %{title: title}}) when title not in [nil, ""] do | ||||
|     gettext("memEx | %{title}", title: title) | ||||
|   end | ||||
|  | ||||
|   def get_title(_conn) do | ||||
|     gettext("memEx") | ||||
|   end | ||||
| end | ||||
							
								
								
									
										45
									
								
								lib/memex_web/components/layouts/app.html.heex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										45
									
								
								lib/memex_web/components/layouts/app.html.heex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,45 @@ | ||||
| <main role="main" class="pb-8 min-w-full"> | ||||
|   <header> | ||||
|     <.topbar current_user={assigns[:current_user]} /> | ||||
|  | ||||
|     <div class="mx-8 my-2 flex flex-col space-y-4 text-center"> | ||||
|       <p | ||||
|         :if={@flash && @flash |> Map.has_key?("info")} | ||||
|         class="alert alert-info cursor-pointer" | ||||
|         role="alert" | ||||
|         phx-click="lv:clear-flash" | ||||
|         phx-value-key="info" | ||||
|       > | ||||
|         <%= live_flash(@flash, :info) %> | ||||
|       </p> | ||||
|  | ||||
|       <p | ||||
|         :if={@flash && @flash |> Map.has_key?("error")} | ||||
|         class="alert alert-danger cursor-pointer" | ||||
|         role="alert" | ||||
|         phx-click="lv:clear-flash" | ||||
|         phx-value-key="error" | ||||
|       > | ||||
|         <%= live_flash(@flash, :error) %> | ||||
|       </p> | ||||
|     </div> | ||||
|   </header> | ||||
|  | ||||
|   <div class="mx-4 sm:mx-8 md:mx-16"> | ||||
|     <%= @inner_content %> | ||||
|   </div> | ||||
| </main> | ||||
|  | ||||
| <div | ||||
|   id="disconnect" | ||||
|   class="z-50 fixed opacity-0 bottom-8 right-12 px-8 py-4 w-max h-max | ||||
|   border border-primary-400 shadow-lg rounded-lg bg-primary-900 text-primary-400 | ||||
|   flex justify-center items-center space-x-4 | ||||
|   transition-opacity ease-in-out duration-500 delay-[2000ms]" | ||||
| > | ||||
|   <i class="fas fa-fade text-md fa-satellite-dish"></i> | ||||
|  | ||||
|   <h1 class="title text-md"> | ||||
|     <%= gettext("Reconnecting...") %> | ||||
|   </h1> | ||||
| </div> | ||||
| @@ -9,11 +9,8 @@ | ||||
| 
 | ||||
|     <hr style="margin: 2em auto; border-width: 1px; border-color: rgb(161, 161, 170); width: 100%; max-width: 42rem;" /> | ||||
| 
 | ||||
|     <a style="color: rgb(161, 161, 170);" href={Routes.live_url(Endpoint, HomeLive)}> | ||||
|       <%= dgettext( | ||||
|         "emails", | ||||
|         "This email was sent from memEx" | ||||
|       ) %> | ||||
|     <a style="color: rgb(161, 161, 170);" href={~p"/"}> | ||||
|       <%= dgettext("emails", "this email was sent from memEx") %> | ||||
|     </a> | ||||
|   </body> | ||||
| </html> | ||||
							
								
								
									
										9
									
								
								lib/memex_web/components/layouts/email_text.txt.eex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								lib/memex_web/components/layouts/email_text.txt.eex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | ||||
| <%= @email.subject %> | ||||
|  | ||||
| ==================== | ||||
|  | ||||
| <%= @inner_content %> | ||||
|  | ||||
| ===================== | ||||
|  | ||||
| <%= dgettext("emails", "this email was sent from memEx at %{url}", url: ~p"/") %> | ||||
| @@ -8,13 +8,8 @@ | ||||
|     <.live_title suffix={" | #{gettext("memEx")}"}> | ||||
|       <%= assigns[:page_title] || gettext("memEx") %> | ||||
|     </.live_title> | ||||
|     <link phx-track-static rel="stylesheet" href={Routes.static_path(@conn, "/css/app.css")} /> | ||||
|     <script | ||||
|       defer | ||||
|       phx-track-static | ||||
|       type="text/javascript" | ||||
|       src={Routes.static_path(@conn, "/js/app.js")} | ||||
|     > | ||||
|     <link phx-track-static rel="stylesheet" href={~p"/css/app.css"} /> | ||||
|     <script defer phx-track-static type="text/javascript" src={~p"/js/app.js"}> | ||||
|     </script> | ||||
|   </head> | ||||
| 
 | ||||
| @@ -1,44 +0,0 @@ | ||||
| defmodule MemexWeb.Components.NoteContent do | ||||
|   @moduledoc """ | ||||
|   Display the content for a note | ||||
|   """ | ||||
|   use MemexWeb, :component | ||||
|   alias Memex.Notes.Note | ||||
|   alias Phoenix.HTML | ||||
|  | ||||
|   attr :note, Note, required: true | ||||
|  | ||||
|   def note_content(assigns) do | ||||
|     ~H""" | ||||
|     <div | ||||
|       id={"show-note-content-#{@note.id}"} | ||||
|       class="input input-primary h-128 min-h-128 inline-block whitespace-pre-wrap overflow-x-hidden overflow-y-auto" | ||||
|       phx-hook="MaintainAttrs" | ||||
|       phx-update="ignore" | ||||
|       readonly | ||||
|       phx-no-format | ||||
|     ><p class="inline"><%= add_links_to_content(@note.content) %></p></div> | ||||
|     """ | ||||
|   end | ||||
|  | ||||
|   defp add_links_to_content(content) do | ||||
|     Regex.replace( | ||||
|       ~r/\[\[([\p{L}\p{N}\-]+)\]\]/, | ||||
|       content, | ||||
|       fn _whole_match, slug -> | ||||
|         link = | ||||
|           HTML.Link.link( | ||||
|             "[[#{slug}]]", | ||||
|             to: Routes.note_show_path(Endpoint, :show, slug), | ||||
|             class: "link inline", | ||||
|             data: [qa: "note-link-#{slug}"] | ||||
|           ) | ||||
|           |> HTML.Safe.to_iodata() | ||||
|           |> IO.iodata_to_binary() | ||||
|  | ||||
|         "</p>#{link}<p class=\"inline\">" | ||||
|       end | ||||
|     ) | ||||
|     |> HTML.raw() | ||||
|   end | ||||
| end | ||||
| @@ -40,7 +40,7 @@ defmodule MemexWeb.Components.NotesTableComponent do | ||||
|       if actions == [] or current_user |> is_nil() do | ||||
|         [] | ||||
|       else | ||||
|         [%{label: nil, key: :actions, sortable: false}] | ||||
|         [%{label: gettext("actions"), key: :actions, sortable: false}] | ||||
|       end | ||||
|  | ||||
|     columns = [ | ||||
| @@ -88,15 +88,9 @@ defmodule MemexWeb.Components.NotesTableComponent do | ||||
|  | ||||
|   @spec get_value_for_key(atom(), Note.t(), additional_data :: map()) :: | ||||
|           any() | {any(), Rendered.t()} | ||||
|   defp get_value_for_key(:slug, %{slug: slug}, _additional_data) do | ||||
|     assigns = %{slug: slug} | ||||
|  | ||||
|   defp get_value_for_key(:slug, %{slug: slug} = assigns, _additional_data) do | ||||
|     slug_block = ~H""" | ||||
|     <.link | ||||
|       navigate={Routes.note_show_path(Endpoint, :show, @slug)} | ||||
|       class="link" | ||||
|       data-qa={"note-show-#{@slug}"} | ||||
|     > | ||||
|     <.link navigate={~p"/note/#{@slug}"} class="link"> | ||||
|       <%= @slug %> | ||||
|     </.link> | ||||
|     """ | ||||
| @@ -104,16 +98,12 @@ defmodule MemexWeb.Components.NotesTableComponent do | ||||
|     {slug, slug_block} | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(:tags, %{tags: tags}, _additional_data) do | ||||
|     assigns = %{tags: tags} | ||||
|  | ||||
|   defp get_value_for_key(:tags, assigns, _additional_data) do | ||||
|     ~H""" | ||||
|     <div class="flex flex-wrap justify-center space-x-1"> | ||||
|       <%= for tag <- @tags do %> | ||||
|         <.link patch={Routes.note_index_path(Endpoint, :search, tag)} class="link"> | ||||
|           <%= tag %> | ||||
|         </.link> | ||||
|       <% end %> | ||||
|       <.link :for={tag <- @tags} patch={~p"/notes/#{tag}"} class="link"> | ||||
|         <%= tag %> | ||||
|       </.link> | ||||
|     </div> | ||||
|     """ | ||||
|   end | ||||
|   | ||||
| @@ -40,7 +40,7 @@ defmodule MemexWeb.Components.PipelinesTableComponent do | ||||
|       if actions == [] or current_user |> is_nil() do | ||||
|         [] | ||||
|       else | ||||
|         [%{label: nil, key: :actions, sortable: false}] | ||||
|         [%{label: gettext("actions"), key: :actions, sortable: false}] | ||||
|       end | ||||
|  | ||||
|     columns = [ | ||||
| @@ -89,15 +89,9 @@ defmodule MemexWeb.Components.PipelinesTableComponent do | ||||
|  | ||||
|   @spec get_value_for_key(atom(), Pipeline.t(), additional_data :: map()) :: | ||||
|           any() | {any(), Rendered.t()} | ||||
|   defp get_value_for_key(:slug, %{slug: slug}, _additional_data) do | ||||
|     assigns = %{slug: slug} | ||||
|  | ||||
|   defp get_value_for_key(:slug, %{slug: slug} = assigns, _additional_data) do | ||||
|     slug_block = ~H""" | ||||
|     <.link | ||||
|       navigate={Routes.pipeline_show_path(Endpoint, :show, @slug)} | ||||
|       class="link" | ||||
|       data-qa={"pipeline-show-#{@slug}"} | ||||
|     > | ||||
|     <.link navigate={~p"/pipeline/#{@slug}"} class="link"> | ||||
|       <%= @slug %> | ||||
|     </.link> | ||||
|     """ | ||||
| @@ -105,9 +99,7 @@ defmodule MemexWeb.Components.PipelinesTableComponent do | ||||
|     {slug, slug_block} | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(:description, %{description: description}, _additional_data) do | ||||
|     assigns = %{description: description} | ||||
|  | ||||
|   defp get_value_for_key(:description, %{description: description} = assigns, _additional_data) do | ||||
|     description_block = ~H""" | ||||
|     <div class="truncate max-w-sm"> | ||||
|       <%= @description %> | ||||
| @@ -117,16 +109,12 @@ defmodule MemexWeb.Components.PipelinesTableComponent do | ||||
|     {description, description_block} | ||||
|   end | ||||
|  | ||||
|   defp get_value_for_key(:tags, %{tags: tags}, _additional_data) do | ||||
|     assigns = %{tags: tags} | ||||
|  | ||||
|   defp get_value_for_key(:tags, assigns, _additional_data) do | ||||
|     ~H""" | ||||
|     <div class="flex flex-wrap justify-center space-x-1"> | ||||
|       <%= for tag <- @tags do %> | ||||
|         <.link patch={Routes.pipeline_index_path(Endpoint, :search, tag)} class="link"> | ||||
|           <%= tag %> | ||||
|         </.link> | ||||
|       <% end %> | ||||
|       <.link :for={tag <- @tags} patch={~p"/pipelines/#{tag}"} class="link"> | ||||
|         <%= tag %> | ||||
|       </.link> | ||||
|     </div> | ||||
|     """ | ||||
|   end | ||||
|   | ||||
| @@ -1,44 +0,0 @@ | ||||
| defmodule MemexWeb.Components.StepContent do | ||||
|   @moduledoc """ | ||||
|   Display the content for a step | ||||
|   """ | ||||
|   use MemexWeb, :component | ||||
|   alias Memex.Pipelines.Steps.Step | ||||
|   alias Phoenix.HTML | ||||
|  | ||||
|   attr :step, Step, required: true | ||||
|  | ||||
|   def step_content(assigns) do | ||||
|     ~H""" | ||||
|     <div | ||||
|       id={"show-step-content-#{@step.id}"} | ||||
|       class="input input-primary h-32 min-h-32 inline-block whitespace-pre-wrap overflow-x-hidden overflow-y-auto" | ||||
|       phx-hook="MaintainAttrs" | ||||
|       phx-update="ignore" | ||||
|       readonly | ||||
|       phx-no-format | ||||
|     ><p class="inline"><%= add_links_to_content(@step.content) %></p></div> | ||||
|     """ | ||||
|   end | ||||
|  | ||||
|   defp add_links_to_content(content) do | ||||
|     Regex.replace( | ||||
|       ~r/\[\[([\p{L}\p{N}\-]+)\]\]/, | ||||
|       content, | ||||
|       fn _whole_match, slug -> | ||||
|         link = | ||||
|           HTML.Link.link( | ||||
|             "[[#{slug}]]", | ||||
|             to: Routes.context_show_path(Endpoint, :show, slug), | ||||
|             class: "link inline", | ||||
|             data: [qa: "step-context-#{slug}"] | ||||
|           ) | ||||
|           |> HTML.Safe.to_iodata() | ||||
|           |> IO.iodata_to_binary() | ||||
|  | ||||
|         "</p>#{link}<p class=\"inline\">" | ||||
|       end | ||||
|     ) | ||||
|     |> HTML.raw() | ||||
|   end | ||||
| end | ||||
| @@ -33,7 +33,8 @@ defmodule MemexWeb.Components.TableComponent do | ||||
|                 optional(:class) => String.t(), | ||||
|                 optional(:row_class) => String.t(), | ||||
|                 optional(:alternate_row_class) => String.t(), | ||||
|                 optional(:sortable) => false | ||||
|                 optional(:sortable) => false, | ||||
|                 optional(:type) => module() | ||||
|               }), | ||||
|             required(:rows) => | ||||
|               list(%{ | ||||
| @@ -60,7 +61,8 @@ defmodule MemexWeb.Components.TableComponent do | ||||
|         :asc | ||||
|       end | ||||
|  | ||||
|     rows = rows |> sort_by_custom_sort_value_or_value(initial_key, initial_sort_mode) | ||||
|     type = columns |> Enum.find(%{}, fn %{key: key} -> key == initial_key end) |> Map.get(:type) | ||||
|     rows = rows |> sort_by_custom_sort_value_or_value(initial_key, initial_sort_mode, type) | ||||
|  | ||||
|     socket = | ||||
|       socket | ||||
| @@ -68,6 +70,7 @@ defmodule MemexWeb.Components.TableComponent do | ||||
|       |> assign( | ||||
|         columns: columns, | ||||
|         rows: rows, | ||||
|         key: initial_key, | ||||
|         last_sort_key: initial_key, | ||||
|         sort_mode: initial_sort_mode | ||||
|       ) | ||||
| @@ -81,7 +84,14 @@ defmodule MemexWeb.Components.TableComponent do | ||||
|   def handle_event( | ||||
|         "sort_by", | ||||
|         %{"sort-key" => key}, | ||||
|         %{assigns: %{rows: rows, last_sort_key: last_sort_key, sort_mode: sort_mode}} = socket | ||||
|         %{ | ||||
|           assigns: %{ | ||||
|             columns: columns, | ||||
|             rows: rows, | ||||
|             last_sort_key: last_sort_key, | ||||
|             sort_mode: sort_mode | ||||
|           } | ||||
|         } = socket | ||||
|       ) do | ||||
|     key = key |> String.to_existing_atom() | ||||
|  | ||||
| @@ -92,11 +102,28 @@ defmodule MemexWeb.Components.TableComponent do | ||||
|         {_new_sort_key, _last_sort_mode} -> :asc | ||||
|       end | ||||
|  | ||||
|     rows = rows |> sort_by_custom_sort_value_or_value(key, sort_mode) | ||||
|     type = | ||||
|       columns |> Enum.find(%{}, fn %{key: column_key} -> column_key == key end) |> Map.get(:type) | ||||
|  | ||||
|     rows = rows |> sort_by_custom_sort_value_or_value(key, sort_mode, type) | ||||
|     {:noreply, socket |> assign(last_sort_key: key, sort_mode: sort_mode, rows: rows)} | ||||
|   end | ||||
|  | ||||
|   defp sort_by_custom_sort_value_or_value(rows, key, sort_mode) do | ||||
|   defp sort_by_custom_sort_value_or_value(rows, key, sort_mode, type) | ||||
|        when type in [Date, DateTime] do | ||||
|     rows | ||||
|     |> Enum.sort_by( | ||||
|       fn row -> | ||||
|         case row |> Map.get(key) do | ||||
|           {custom_sort_key, _value} -> custom_sort_key | ||||
|           value -> value | ||||
|         end | ||||
|       end, | ||||
|       {sort_mode, type} | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   defp sort_by_custom_sort_value_or_value(rows, key, sort_mode, _type) do | ||||
|     rows | ||||
|     |> Enum.sort_by( | ||||
|       fn row -> | ||||
| @@ -108,4 +135,25 @@ defmodule MemexWeb.Components.TableComponent do | ||||
|       sort_mode | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Conditionally composes elements into the columns list, supports maps and | ||||
|   lists. Works tail to front in order for efficiency | ||||
|  | ||||
|       iex> [] | ||||
|       ...> |> maybe_compose_columns(%{label: "Column 3"}, true) | ||||
|       ...> |> maybe_compose_columns(%{label: "Column 2"}, false) | ||||
|       ...> |> maybe_compose_columns(%{label: "Column 1"}) | ||||
|       [%{label: "Column 1"}, %{label: "Column 3"}] | ||||
|  | ||||
|   """ | ||||
|   @spec maybe_compose_columns(list(), element_to_add :: list() | map()) :: list() | ||||
|   @spec maybe_compose_columns(list(), element_to_add :: list() | map(), boolean()) :: list() | ||||
|   def maybe_compose_columns(columns, element_or_elements, add? \\ true) | ||||
|  | ||||
|   def maybe_compose_columns(columns, elements, true) when is_list(elements), | ||||
|     do: Enum.concat(elements, columns) | ||||
|  | ||||
|   def maybe_compose_columns(columns, element, true) when is_map(element), do: [element | columns] | ||||
|   def maybe_compose_columns(columns, _element_or_elements, false), do: columns | ||||
| end | ||||
|   | ||||
| @@ -11,7 +11,8 @@ | ||||
|                 phx-value-sort-key={key} | ||||
|                 phx-target={@myself} | ||||
|               > | ||||
|                 <span><%= label %></span> | ||||
|                 <i class="w-0 float-right fas fa-sm fa-chevron-up opacity-0"></i> | ||||
|                 <span class={if @last_sort_key == key, do: "underline"}><%= label %></span> | ||||
|                 <%= if @last_sort_key == key do %> | ||||
|                   <%= case @sort_mode do %> | ||||
|                     <% :asc -> %> | ||||
| @@ -25,7 +26,7 @@ | ||||
|               </span> | ||||
|             </th> | ||||
|           <% else %> | ||||
|             <th class={["p-2", column[:class]]}> | ||||
|             <th class={["p-2 cursor-not-allowed", column[:class]]}> | ||||
|               <%= label %> | ||||
|             </th> | ||||
|           <% end %> | ||||
| @@ -33,20 +34,19 @@ | ||||
|       </tr> | ||||
|     </thead> | ||||
|     <tbody> | ||||
|       <%= for {values, i} <- @rows |> Enum.with_index() do %> | ||||
|         <tr class={if i |> Integer.is_even(), do: @row_class, else: @alternate_row_class}> | ||||
|           <%= for %{key: key} = value <- @columns do %> | ||||
|             <td class={["p-2", value[:class]]}> | ||||
|               <%= case values |> Map.get(key) do %> | ||||
|                 <% {_custom_sort_value, value} -> %> | ||||
|                   <%= value %> | ||||
|                 <% value -> %> | ||||
|                   <%= value %> | ||||
|               <% end %> | ||||
|             </td> | ||||
|       <tr | ||||
|         :for={{values, i} <- @rows |> Enum.with_index()} | ||||
|         class={if i |> Integer.is_even(), do: @row_class, else: @alternate_row_class} | ||||
|       > | ||||
|         <td :for={%{key: key} = value <- @columns} class={["p-2", value[:class]]}> | ||||
|           <%= case values |> Map.get(key) do %> | ||||
|             <% {_custom_sort_value, value} -> %> | ||||
|               <%= value %> | ||||
|             <% value -> %> | ||||
|               <%= value %> | ||||
|           <% end %> | ||||
|         </tr> | ||||
|       <% end %> | ||||
|         </td> | ||||
|       </tr> | ||||
|     </tbody> | ||||
|   </table> | ||||
| </div> | ||||
|   | ||||
| @@ -1,134 +0,0 @@ | ||||
| defmodule MemexWeb.Components.Topbar do | ||||
|   @moduledoc """ | ||||
|   Component that renders a topbar with user functions/links | ||||
|   """ | ||||
|  | ||||
|   use MemexWeb, :component | ||||
|  | ||||
|   alias Memex.Accounts | ||||
|   alias MemexWeb.HomeLive | ||||
|  | ||||
|   def topbar(assigns) do | ||||
|     assigns = | ||||
|       %{results: [], title_content: nil, flash: nil, current_user: nil} |> Map.merge(assigns) | ||||
|  | ||||
|     ~H""" | ||||
|     <nav role="navigation" class="mb-8 px-8 py-4 w-full bg-primary-900 text-primary-400"> | ||||
|       <div class="flex flex-col sm:flex-row justify-between items-center"> | ||||
|         <div class="mb-4 sm:mb-0 sm:mr-8 flex flex-row justify-start items-center space-x-2"> | ||||
|           <.link | ||||
|             navigate={Routes.live_path(Endpoint, HomeLive)} | ||||
|             class="mx-2 my-1 leading-5 text-xl text-primary-400 hover:underline" | ||||
|           > | ||||
|             <%= gettext("memEx") %> | ||||
|           </.link> | ||||
|  | ||||
|           <%= if @title_content do %> | ||||
|             <span class="mx-2 my-1"> | ||||
|               | | ||||
|             </span> | ||||
|             <%= @title_content %> | ||||
|           <% end %> | ||||
|         </div> | ||||
|  | ||||
|         <hr class="mb-2 sm:hidden hr-light" /> | ||||
|  | ||||
|         <ul class="flex flex-row flex-wrap justify-center items-center | ||||
|           text-lg text-primary-400 text-ellipsis"> | ||||
|           <li class="mx-2 my-1"> | ||||
|             <.link | ||||
|               navigate={Routes.note_index_path(Endpoint, :index)} | ||||
|               class="text-primary-400 text-primary-400 hover:underline truncate" | ||||
|             > | ||||
|               <%= gettext("notes") %> | ||||
|             </.link> | ||||
|           </li> | ||||
|  | ||||
|           <li class="mx-2 my-1"> | ||||
|             <.link | ||||
|               navigate={Routes.context_index_path(Endpoint, :index)} | ||||
|               class="text-primary-400 text-primary-400 hover:underline truncate" | ||||
|             > | ||||
|               <%= gettext("contexts") %> | ||||
|             </.link> | ||||
|           </li> | ||||
|  | ||||
|           <li class="mx-2 my-1"> | ||||
|             <.link | ||||
|               navigate={Routes.pipeline_index_path(Endpoint, :index)} | ||||
|               class="text-primary-400 text-primary-400 hover:underline truncate" | ||||
|             > | ||||
|               <%= gettext("pipelines") %> | ||||
|             </.link> | ||||
|           </li> | ||||
|  | ||||
|           <li class="mx-2 my-1 border-left border border-primary-700"></li> | ||||
|  | ||||
|           <%= if @current_user do %> | ||||
|             <%= if @current_user |> Accounts.is_already_admin?() do %> | ||||
|               <li class="mx-2 my-1"> | ||||
|                 <.link | ||||
|                   navigate={Routes.invite_index_path(Endpoint, :index)} | ||||
|                   class="text-primary-400 text-primary-400 hover:underline" | ||||
|                 > | ||||
|                   <%= gettext("invites") %> | ||||
|                 </.link> | ||||
|               </li> | ||||
|             <% end %> | ||||
|  | ||||
|             <li class="mx-2 my-1"> | ||||
|               <.link | ||||
|                 navigate={Routes.user_settings_path(Endpoint, :edit)} | ||||
|                 class="text-primary-400 text-primary-400 hover:underline truncate" | ||||
|               > | ||||
|                 <%= @current_user.email %> | ||||
|               </.link> | ||||
|             </li> | ||||
|  | ||||
|             <li class="mx-2 my-1"> | ||||
|               <.link | ||||
|                 href={Routes.user_session_path(Endpoint, :delete)} | ||||
|                 method="delete" | ||||
|                 data-confirm={dgettext("prompts", "are you sure you want to log out?")} | ||||
|               > | ||||
|                 <i class="fas fa-sign-out-alt"></i> | ||||
|               </.link> | ||||
|             </li> | ||||
|  | ||||
|             <%= if @current_user.role == :admin and function_exported?(Routes, :live_dashboard_path, 2) do %> | ||||
|               <li class="mx-2 my-1"> | ||||
|                 <.link | ||||
|                   navigate={Routes.live_dashboard_path(Endpoint, :home)} | ||||
|                   class="text-primary-400 text-primary-400 hover:underline" | ||||
|                 > | ||||
|                   <i class="fas fa-gauge"></i> | ||||
|                 </.link> | ||||
|               </li> | ||||
|             <% end %> | ||||
|           <% else %> | ||||
|             <%= if Accounts.allow_registration?() do %> | ||||
|               <li class="mx-2 my-1"> | ||||
|                 <.link | ||||
|                   navigate={Routes.user_registration_path(Endpoint, :new)} | ||||
|                   class="text-primary-400 text-primary-400 hover:underline truncate" | ||||
|                 > | ||||
|                   <%= dgettext("actions", "register") %> | ||||
|                 </.link> | ||||
|               </li> | ||||
|             <% end %> | ||||
|  | ||||
|             <li class="mx-2 my-1"> | ||||
|               <.link | ||||
|                 navigate={Routes.user_session_path(Endpoint, :new)} | ||||
|                 class="text-primary-400 text-primary-400 hover:underline truncate" | ||||
|               > | ||||
|                 <%= dgettext("actions", "log in") %> | ||||
|               </.link> | ||||
|             </li> | ||||
|           <% end %> | ||||
|         </ul> | ||||
|       </div> | ||||
|     </nav> | ||||
|     """ | ||||
|   end | ||||
| end | ||||
| @@ -1,50 +0,0 @@ | ||||
| defmodule MemexWeb.Components.UserCard do | ||||
|   @moduledoc """ | ||||
|   Display card for a user | ||||
|   """ | ||||
|  | ||||
|   use MemexWeb, :component | ||||
|  | ||||
|   def user_card(assigns) do | ||||
|     ~H""" | ||||
|     <div | ||||
|       id={"user-#{@user.id}"} | ||||
|       class="mx-4 my-2 px-8 py-4 flex flex-col justify-center items-center text-center | ||||
|           border border-gray-400 rounded-lg shadow-lg hover:shadow-md | ||||
|           transition-all duration-300 ease-in-out" | ||||
|     > | ||||
|       <h1 class="px-4 py-2 rounded-lg title text-xl break-all"> | ||||
|         <%= @user.email %> | ||||
|       </h1> | ||||
|  | ||||
|       <h3 class="px-4 py-2 rounded-lg title text-lg"> | ||||
|         <p> | ||||
|           <%= if @user.confirmed_at do %> | ||||
|             <%= gettext( | ||||
|               "user confirmed on%{confirmed_datetime}", | ||||
|               confirmed_datetime: "" | ||||
|             ) %> | ||||
|             <.datetime datetime={@user.confirmed_at} /> | ||||
|           <% else %> | ||||
|             <%= gettext("email unconfirmed") %> | ||||
|           <% end %> | ||||
|         </p> | ||||
|  | ||||
|         <p> | ||||
|           <%= gettext( | ||||
|             "user registered on%{registered_datetime}", | ||||
|             registered_datetime: "" | ||||
|           ) %> | ||||
|           <.datetime datetime={@user.inserted_at} /> | ||||
|         </p> | ||||
|       </h3> | ||||
|  | ||||
|       <%= if @inner_block do %> | ||||
|         <div class="px-4 py-2 flex space-x-4 justify-center items-center"> | ||||
|           <%= render_slot(@inner_block) %> | ||||
|         </div> | ||||
|       <% end %> | ||||
|     </div> | ||||
|     """ | ||||
|   end | ||||
| end | ||||
| @@ -6,7 +6,8 @@ defmodule MemexWeb.EmailController do | ||||
|   use MemexWeb, :controller | ||||
|   alias Memex.Accounts.User | ||||
|  | ||||
|   plug :put_layout, {MemexWeb.LayoutView, :email} | ||||
|   plug :put_root_layout, html: {MemexWeb.Layouts, :email_html} | ||||
|   plug :put_layout, false | ||||
|  | ||||
|   @sample_assigns %{ | ||||
|     email: %{subject: "Example subject"}, | ||||
| @@ -18,6 +19,6 @@ defmodule MemexWeb.EmailController do | ||||
|   Debug route used to preview emails | ||||
|   """ | ||||
|   def preview(conn, %{"id" => template}) do | ||||
|     render(conn, "#{template |> to_string()}.html", @sample_assigns) | ||||
|     render(conn, String.to_existing_atom(template), @sample_assigns) | ||||
|   end | ||||
| end | ||||
|   | ||||
							
								
								
									
										10
									
								
								lib/memex_web/controllers/email_html.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								lib/memex_web/controllers/email_html.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | ||||
| defmodule MemexWeb.EmailHTML do | ||||
|   @moduledoc """ | ||||
|   Renders email templates | ||||
|   """ | ||||
|  | ||||
|   use MemexWeb, :html | ||||
|  | ||||
|   embed_templates "email_html/*.html", suffix: "_html" | ||||
|   embed_templates "email_html/*.txt", suffix: "_text" | ||||
| end | ||||
| @@ -15,7 +15,7 @@ | ||||
| 
 | ||||
|   <br /> | ||||
| 
 | ||||
|   <a style="margin: 1em; color: rgb(31, 31, 31);" href={@url}><%= @url %></a> | ||||
|   <a style="margin: 1em; color: rgb(161, 161, 170);" href={@url}><%= @url %></a> | ||||
| 
 | ||||
|   <br /> | ||||
| 
 | ||||
| @@ -9,4 +9,4 @@ | ||||
| 
 | ||||
| <%= dgettext("emails", | ||||
|   "If you didn't create an account at %{url}, please ignore this.", | ||||
|   url: Routes.live_url(Endpoint, HomeLive)) %> | ||||
|   url: ~p"/") %> | ||||
| @@ -9,7 +9,7 @@ | ||||
| 
 | ||||
|   <br /> | ||||
| 
 | ||||
|   <a style="margin: 1em; color: rgb(31, 31, 31);" href={@url}><%= @url %></a> | ||||
|   <a style="margin: 1em; color: rgb(161, 161, 170);" href={@url}><%= @url %></a> | ||||
| 
 | ||||
|   <br /> | ||||
| 
 | ||||
| @@ -7,4 +7,4 @@ | ||||
| 
 | ||||
| <%= dgettext("emails", | ||||
|   "If you didn't request this change from %{url}, please ignore this.", | ||||
|   url: Routes.live_url(Endpoint, HomeLive)) %> | ||||
|   url: ~p"/") %> | ||||
| @@ -9,7 +9,7 @@ | ||||
| 
 | ||||
|   <br /> | ||||
| 
 | ||||
|   <a style="margin: 1em; color: rgb(31, 31, 31);" href={@url}><%= @url %></a> | ||||
|   <a style="margin: 1em; color: rgb(161, 161, 170);" href={@url}><%= @url %></a> | ||||
| 
 | ||||
|   <br /> | ||||
| 
 | ||||
| @@ -7,4 +7,4 @@ | ||||
| 
 | ||||
| <%= dgettext("emails", | ||||
|   "If you didn't request this change from %{url}, please ignore this.", | ||||
|   url: Routes.live_url(Endpoint, HomeLive)) %> | ||||
|   url: ~p"/") %> | ||||
							
								
								
									
										16
									
								
								lib/memex_web/controllers/error_html.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								lib/memex_web/controllers/error_html.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | ||||
| defmodule MemexWeb.ErrorHTML do | ||||
|   use MemexWeb, :html | ||||
|  | ||||
|   embed_templates "error_html/*" | ||||
|  | ||||
|   def render(template, _assigns) do | ||||
|     error_string = | ||||
|       case template do | ||||
|         "404.html" -> dgettext("errors", "not found") | ||||
|         "401.html" -> dgettext("errors", "unauthorized") | ||||
|         _other_path -> dgettext("errors", "internal server error") | ||||
|       end | ||||
|  | ||||
|     error(%{error_string: error_string}) | ||||
|   end | ||||
| end | ||||
| @@ -5,15 +5,15 @@ | ||||
|     <meta http-equiv="X-UA-Compatible" content="IE=edge" /> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0" /> | ||||
|     <title> | ||||
|       <%= dgettext("errors", "Error") %> | memEx | ||||
|       <%= dgettext("errors", "Error") %> | <%= gettext("memEx") %> | ||||
|     </title> | ||||
|     <link rel="stylesheet" href="/css/app.css" /> | ||||
|     <script defer type="text/javascript" src="/js/app.js"> | ||||
|     </script> | ||||
|   </head> | ||||
|   <body class="m-0 p-0 w-full h-full bg-primary-800 text-primary-400 subpixel-antialiased"> | ||||
|   <body class="pb-8 m-0 p-0 w-full h-full bg-primary-800 text-primary-400 subpixel-antialiased"> | ||||
|     <header> | ||||
|       <.topbar current_user={assigns[:current_user]}></.topbar> | ||||
|       <.topbar current_user={assigns[:current_user]} /> | ||||
|     </header> | ||||
| 
 | ||||
|     <div class="pb-8 w-full flex flex-col justify-center items-center text-center"> | ||||
| @@ -24,9 +24,9 @@ | ||||
| 
 | ||||
|         <hr class="w-full hr" /> | ||||
| 
 | ||||
|         <a href={Routes.live_path(Endpoint, HomeLive)} class="link title text-primary-400 text-lg"> | ||||
|         <.link href={~p"/"} class="link title text-primary-400 text-lg"> | ||||
|           <%= dgettext("errors", "go back home") %> | ||||
|         </a> | ||||
|         </.link> | ||||
|       </div> | ||||
|     </div> | ||||
|   </body> | ||||
							
								
								
									
										14
									
								
								lib/memex_web/controllers/error_json.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								lib/memex_web/controllers/error_json.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | ||||
| defmodule MemexWeb.ErrorJSON do | ||||
|   import MemexWeb.Gettext | ||||
|  | ||||
|   def render(template, _assigns) do | ||||
|     error_string = | ||||
|       case template do | ||||
|         "404.json" -> dgettext("errors", "not found") | ||||
|         "401.json" -> dgettext("errors", "unauthorized") | ||||
|         _other_path -> dgettext("errors", "internal server error") | ||||
|       end | ||||
|  | ||||
|     %{errors: %{detail: error_string}} | ||||
|   end | ||||
| end | ||||
| @@ -1,11 +0,0 @@ | ||||
| defmodule MemexWeb.HomeController do | ||||
|   @moduledoc """ | ||||
|   Controller for home page | ||||
|   """ | ||||
|  | ||||
|   use MemexWeb, :controller | ||||
|  | ||||
|   def index(conn, _params) do | ||||
|     render(conn, "index.html") | ||||
|   end | ||||
| end | ||||
							
								
								
									
										5
									
								
								lib/memex_web/controllers/home_html.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								lib/memex_web/controllers/home_html.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| defmodule MemexWeb.HomeHTML do | ||||
|   use MemexWeb, :html | ||||
|  | ||||
|   embed_templates "home_html/*" | ||||
| end | ||||
| @@ -3,12 +3,11 @@ defmodule MemexWeb.UserAuth do | ||||
|   Functions for user session and authentication | ||||
|   """ | ||||
|  | ||||
|   use MemexWeb, :verified_routes | ||||
|   import Plug.Conn | ||||
|   import Phoenix.Controller | ||||
|   import MemexWeb.Gettext | ||||
|   alias Memex.{Accounts, Accounts.User} | ||||
|   alias MemexWeb.HomeLive | ||||
|   alias MemexWeb.Router.Helpers, as: Routes | ||||
|  | ||||
|   # Make the remember me cookie valid for 60 days. | ||||
|   # If you want bump or reduce this value, also change | ||||
| @@ -39,7 +38,7 @@ defmodule MemexWeb.UserAuth do | ||||
|       dgettext("errors", "You must confirm your account and log in to access this page.") | ||||
|     ) | ||||
|     |> maybe_store_return_to() | ||||
|     |> redirect(to: Routes.user_session_path(conn, :new)) | ||||
|     |> redirect(to: ~p"/users/log_in") | ||||
|     |> halt() | ||||
|   end | ||||
|  | ||||
| @@ -49,8 +48,7 @@ defmodule MemexWeb.UserAuth do | ||||
|  | ||||
|     conn | ||||
|     |> renew_session() | ||||
|     |> put_session(:user_token, token) | ||||
|     |> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}") | ||||
|     |> put_token_in_session(token) | ||||
|     |> maybe_write_remember_me_cookie(token, params) | ||||
|     |> redirect(to: user_return_to || signed_in_path(conn)) | ||||
|   end | ||||
| @@ -96,7 +94,7 @@ defmodule MemexWeb.UserAuth do | ||||
|   """ | ||||
|   def log_out_user(conn) do | ||||
|     user_token = get_session(conn, :user_token) | ||||
|     user_token && Accounts.delete_session_token(user_token) | ||||
|     user_token && Accounts.delete_user_session_token(user_token) | ||||
|  | ||||
|     if live_socket_id = get_session(conn, :live_socket_id) do | ||||
|       MemexWeb.Endpoint.broadcast(live_socket_id, "disconnect", %{}) | ||||
| @@ -105,7 +103,7 @@ defmodule MemexWeb.UserAuth do | ||||
|     conn | ||||
|     |> renew_session() | ||||
|     |> delete_resp_cookie(@remember_me_cookie) | ||||
|     |> redirect(to: "/") | ||||
|     |> redirect(to: ~p"/") | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
| @@ -119,19 +117,110 @@ defmodule MemexWeb.UserAuth do | ||||
|   end | ||||
|  | ||||
|   defp ensure_user_token(conn) do | ||||
|     if user_token = get_session(conn, :user_token) do | ||||
|       {user_token, conn} | ||||
|     if token = get_session(conn, :user_token) do | ||||
|       {token, conn} | ||||
|     else | ||||
|       conn = fetch_cookies(conn, signed: [@remember_me_cookie]) | ||||
|  | ||||
|       if user_token = conn.cookies[@remember_me_cookie] do | ||||
|         {user_token, put_session(conn, :user_token, user_token)} | ||||
|       if token = conn.cookies[@remember_me_cookie] do | ||||
|         {token, put_token_in_session(conn, token)} | ||||
|       else | ||||
|         {nil, conn} | ||||
|       end | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Handles mounting and authenticating the current_user in LiveViews. | ||||
|  | ||||
|   ## `on_mount` arguments | ||||
|  | ||||
|     * `:mount_current_user` - Assigns current_user | ||||
|       to socket assigns based on user_token, or nil if | ||||
|       there's no user_token or no matching user. | ||||
|  | ||||
|     * `:ensure_authenticated` - Authenticates the user from the session, | ||||
|       and assigns the current_user to socket assigns based | ||||
|       on user_token. | ||||
|       Redirects to login page if there's no logged user. | ||||
|  | ||||
|     * `:redirect_if_user_is_authenticated` - Authenticates the user from the session. | ||||
|       Redirects to signed_in_path if there's a logged user. | ||||
|  | ||||
|   ## Examples | ||||
|  | ||||
|   Use the `on_mount` lifecycle macro in LiveViews to mount or authenticate | ||||
|   the current_user: | ||||
|  | ||||
|       defmodule MemexWeb.PageLive do | ||||
|         use MemexWeb, :live_view | ||||
|  | ||||
|         on_mount {MemexWeb.UserAuth, :mount_current_user} | ||||
|         ... | ||||
|       end | ||||
|  | ||||
|   Or use the `live_session` of your router to invoke the on_mount callback: | ||||
|  | ||||
|       live_session :authenticated, on_mount: [{MemexWeb.UserAuth, :ensure_authenticated}] do | ||||
|         live "/profile", ProfileLive, :index | ||||
|       end | ||||
|   """ | ||||
|   def on_mount(:mount_current_user, _params, session, socket) do | ||||
|     {:cont, mount_current_user(session, socket)} | ||||
|   end | ||||
|  | ||||
|   def on_mount(:ensure_authenticated, _params, session, socket) do | ||||
|     socket = mount_current_user(session, socket) | ||||
|  | ||||
|     if socket.assigns.current_user do | ||||
|       {:cont, socket} | ||||
|     else | ||||
|       error_flash = dgettext("errors", "You must log in to access this page.") | ||||
|  | ||||
|       socket = | ||||
|         socket | ||||
|         |> Phoenix.LiveView.put_flash(:error, error_flash) | ||||
|         |> Phoenix.LiveView.redirect(to: ~p"/users/log_in") | ||||
|  | ||||
|       {:halt, socket} | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def on_mount(:ensure_admin, _params, session, socket) do | ||||
|     socket = mount_current_user(session, socket) | ||||
|  | ||||
|     if socket.assigns.current_user && socket.assigns.current_user.role == :admin do | ||||
|       {:cont, socket} | ||||
|     else | ||||
|       error_flash = dgettext("errors", "You must log in as an administrator to access this page.") | ||||
|  | ||||
|       socket = | ||||
|         socket | ||||
|         |> Phoenix.LiveView.put_flash(:error, error_flash) | ||||
|         |> Phoenix.LiveView.redirect(to: ~p"/users/log_in") | ||||
|  | ||||
|       {:halt, socket} | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def on_mount(:redirect_if_user_is_authenticated, _params, session, socket) do | ||||
|     socket = mount_current_user(session, socket) | ||||
|  | ||||
|     if socket.assigns.current_user do | ||||
|       {:halt, Phoenix.LiveView.redirect(socket, to: signed_in_path(socket))} | ||||
|     else | ||||
|       {:cont, socket} | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   defp mount_current_user(session, socket) do | ||||
|     Phoenix.Component.assign_new(socket, :current_user, fn -> | ||||
|       if user_token = session["user_token"] do | ||||
|         Accounts.get_user_by_session_token(user_token) | ||||
|       end | ||||
|     end) | ||||
|   end | ||||
|  | ||||
|   @doc """ | ||||
|   Used for routes that require the user to not be authenticated. | ||||
|   """ | ||||
| @@ -161,7 +250,7 @@ defmodule MemexWeb.UserAuth do | ||||
|         dgettext("errors", "You must confirm your account and log in to access this page.") | ||||
|       ) | ||||
|       |> maybe_store_return_to() | ||||
|       |> redirect(to: Routes.user_session_path(conn, :new)) | ||||
|       |> redirect(to: ~p"/users/log_in") | ||||
|       |> halt() | ||||
|     end | ||||
|   end | ||||
| @@ -176,16 +265,34 @@ defmodule MemexWeb.UserAuth do | ||||
|       conn | ||||
|       |> put_flash(:error, dgettext("errors", "You are not authorized to view this page.")) | ||||
|       |> maybe_store_return_to() | ||||
|       |> redirect(to: Routes.live_path(conn, HomeLive)) | ||||
|       |> redirect(to: ~p"/") | ||||
|       |> halt() | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   def put_user_locale(%{assigns: %{current_user: %{locale: locale}}} = conn, _opts) do | ||||
|     default = Application.fetch_env!(:gettext, :default_locale) | ||||
|     Gettext.put_locale(locale || default) | ||||
|     conn |> put_session(:locale, locale || default) | ||||
|   end | ||||
|  | ||||
|   def put_user_locale(conn, _opts) do | ||||
|     default = Application.fetch_env!(:gettext, :default_locale) | ||||
|     Gettext.put_locale(default) | ||||
|     conn |> put_session(:locale, default) | ||||
|   end | ||||
|  | ||||
|   defp put_token_in_session(conn, token) do | ||||
|     conn | ||||
|     |> put_session(:user_token, token) | ||||
|     |> put_session(:live_socket_id, "users_sessions:#{Base.url_encode64(token)}") | ||||
|   end | ||||
|  | ||||
|   defp maybe_store_return_to(%{method: "GET"} = conn) do | ||||
|     put_session(conn, :user_return_to, current_path(conn)) | ||||
|   end | ||||
|  | ||||
|   defp maybe_store_return_to(conn), do: conn | ||||
|  | ||||
|   defp signed_in_path(_conn), do: "/" | ||||
|   defp signed_in_path(_conn), do: ~p"/" | ||||
| end | ||||
|   | ||||
| @@ -5,14 +5,14 @@ defmodule MemexWeb.UserConfirmationController do | ||||
|   alias Memex.Accounts | ||||
|  | ||||
|   def new(conn, _params) do | ||||
|     render(conn, "new.html", page_title: gettext("Confirm your account")) | ||||
|     render(conn, :new, page_title: gettext("confirm your account")) | ||||
|   end | ||||
|  | ||||
|   def create(conn, %{"user" => %{"email" => email}}) do | ||||
|     if user = Accounts.get_user_by_email(email) do | ||||
|       Accounts.deliver_user_confirmation_instructions( | ||||
|         user, | ||||
|         &Routes.user_confirmation_url(conn, :confirm, &1) | ||||
|         fn token -> url(MemexWeb.Endpoint, ~p"/users/confirm/#{token}") end | ||||
|       ) | ||||
|     end | ||||
|  | ||||
| @@ -22,11 +22,10 @@ defmodule MemexWeb.UserConfirmationController do | ||||
|       :info, | ||||
|       dgettext( | ||||
|         "prompts", | ||||
|         "If your email is in our system and it has not been confirmed yet, " <> | ||||
|           "you will receive an email with instructions shortly." | ||||
|         "if your email is in our system and it has not been confirmed yet, you will receive an email with instructions shortly." | ||||
|       ) | ||||
|     ) | ||||
|     |> redirect(to: "/") | ||||
|     |> redirect(to: ~p"/") | ||||
|   end | ||||
|  | ||||
|   # Do not log in the user after confirmation to avoid a | ||||
| @@ -36,7 +35,7 @@ defmodule MemexWeb.UserConfirmationController do | ||||
|       {:ok, %{email: email}} -> | ||||
|         conn | ||||
|         |> put_flash(:info, dgettext("prompts", "%{email} confirmed successfully.", email: email)) | ||||
|         |> redirect(to: "/") | ||||
|         |> redirect(to: ~p"/") | ||||
|  | ||||
|       :error -> | ||||
|         # If there is a current user and the account was already confirmed, | ||||
| @@ -45,15 +44,15 @@ defmodule MemexWeb.UserConfirmationController do | ||||
|         # a warning message. | ||||
|         case conn.assigns do | ||||
|           %{current_user: %{confirmed_at: confirmed_at}} when not is_nil(confirmed_at) -> | ||||
|             redirect(conn, to: "/") | ||||
|             redirect(conn, to: ~p"/") | ||||
|  | ||||
|           %{} -> | ||||
|             conn | ||||
|             |> put_flash( | ||||
|               :error, | ||||
|               dgettext("errors", "User confirmation link is invalid or it has expired.") | ||||
|               dgettext("errors", "user confirmation link is invalid or it has expired.") | ||||
|             ) | ||||
|             |> redirect(to: "/") | ||||
|             |> redirect(to: ~p"/") | ||||
|         end | ||||
|     end | ||||
|   end | ||||
|   | ||||
							
								
								
									
										6
									
								
								lib/memex_web/controllers/user_confirmation_html.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								lib/memex_web/controllers/user_confirmation_html.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| defmodule MemexWeb.UserConfirmationHTML do | ||||
|   use MemexWeb, :html | ||||
|   alias Memex.Accounts | ||||
|  | ||||
|   embed_templates "user_confirmation_html/*" | ||||
| end | ||||
| @@ -1,15 +1,16 @@ | ||||
| <div class="mx-auto mb-8 max-w-2xl flex flex-col justify-center items-center space-y-4"> | ||||
| <div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4"> | ||||
|   <h1 class="title text-primary-400 text-xl"> | ||||
|     <%= dgettext("actions", "Resend confirmation instructions") %> | ||||
|   </h1> | ||||
| 
 | ||||
|   <.form | ||||
|     :let={f} | ||||
|     for={:user} | ||||
|     action={Routes.user_confirmation_path(@conn, :create)} | ||||
|     for={%{}} | ||||
|     as={:user} | ||||
|     action={~p"/users/confirm"} | ||||
|     class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" | ||||
|   > | ||||
|     <%= label(f, :email, class: "title text-lg text-primary-400") %> | ||||
|     <%= label(f, :email, gettext("Email"), class: "title text-lg text-primary-400") %> | ||||
|     <%= email_input(f, :email, required: true, class: "input input-primary col-span-2") %> | ||||
| 
 | ||||
|     <%= submit(dgettext("actions", "Resend confirmation instructions"), | ||||
| @@ -20,12 +21,10 @@ | ||||
|   <hr class="hr" /> | ||||
| 
 | ||||
|   <div class="flex flex-row justify-center items-center space-x-4"> | ||||
|     <%= if Accounts.allow_registration?() do %> | ||||
|       <.link href={Routes.user_registration_path(@conn, :new)} class="btn btn-primary"> | ||||
|         <%= dgettext("actions", "register") %> | ||||
|       </.link> | ||||
|     <% end %> | ||||
|     <.link href={Routes.user_session_path(@conn, :new)} class="btn btn-primary"> | ||||
|     <.link :if={Accounts.allow_registration?()} href={~p"/users/register"} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "register") %> | ||||
|     </.link> | ||||
|     <.link href={~p"/users/log_in"} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "log in") %> | ||||
|     </.link> | ||||
|   </div> | ||||
| @@ -1,18 +1,16 @@ | ||||
| defmodule MemexWeb.UserRegistrationController do | ||||
|   use MemexWeb, :controller | ||||
|   import MemexWeb.Gettext | ||||
|   alias Memex.{Accounts, Invites} | ||||
|   alias MemexWeb.HomeLive | ||||
|   alias Ecto.Changeset | ||||
|   alias Memex.{Accounts, Accounts.Invites} | ||||
|  | ||||
|   def new(conn, %{"invite" => invite_token}) do | ||||
|     invite = Invites.get_invite_by_token(invite_token) | ||||
|  | ||||
|     if invite do | ||||
|       conn |> render_new(invite) | ||||
|     if Invites.valid_invite_token?(invite_token) do | ||||
|       conn |> render_new(invite_token) | ||||
|     else | ||||
|       conn | ||||
|       |> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired")) | ||||
|       |> redirect(to: Routes.live_path(Endpoint, HomeLive)) | ||||
|       |> put_flash(:error, dgettext("errors", "sorry, this invite was not found or expired")) | ||||
|       |> redirect(to: ~p"/") | ||||
|     end | ||||
|   end | ||||
|  | ||||
| @@ -21,29 +19,27 @@ defmodule MemexWeb.UserRegistrationController do | ||||
|       conn |> render_new() | ||||
|     else | ||||
|       conn | ||||
|       |> put_flash(:error, dgettext("errors", "Sorry, public registration is disabled")) | ||||
|       |> redirect(to: Routes.live_path(Endpoint, HomeLive)) | ||||
|       |> put_flash(:error, dgettext("errors", "sorry, public registration is disabled")) | ||||
|       |> redirect(to: ~p"/") | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   # renders new user registration page | ||||
|   defp render_new(conn, invite \\ nil) do | ||||
|     render(conn, "new.html", | ||||
|   defp render_new(conn, invite_token \\ nil) do | ||||
|     render(conn, :new, | ||||
|       changeset: Accounts.change_user_registration(), | ||||
|       invite: invite, | ||||
|       invite_token: invite_token, | ||||
|       page_title: gettext("register") | ||||
|     ) | ||||
|   end | ||||
|  | ||||
|   def create(conn, %{"user" => %{"invite_token" => invite_token}} = attrs) do | ||||
|     invite = Invites.get_invite_by_token(invite_token) | ||||
|  | ||||
|     if invite do | ||||
|       conn |> create_user(attrs, invite) | ||||
|     if Invites.valid_invite_token?(invite_token) do | ||||
|       conn |> create_user(attrs, invite_token) | ||||
|     else | ||||
|       conn | ||||
|       |> put_flash(:error, dgettext("errors", "Sorry, this invite was not found or expired")) | ||||
|       |> redirect(to: Routes.live_path(Endpoint, HomeLive)) | ||||
|       |> put_flash(:error, dgettext("errors", "sorry, this invite was not found or expired")) | ||||
|       |> redirect(to: ~p"/") | ||||
|     end | ||||
|   end | ||||
|  | ||||
| @@ -52,29 +48,30 @@ defmodule MemexWeb.UserRegistrationController do | ||||
|       conn |> create_user(attrs) | ||||
|     else | ||||
|       conn | ||||
|       |> put_flash(:error, dgettext("errors", "Sorry, public registration is disabled")) | ||||
|       |> redirect(to: Routes.live_path(Endpoint, HomeLive)) | ||||
|       |> put_flash(:error, dgettext("errors", "sorry, public registration is disabled")) | ||||
|       |> redirect(to: ~p"/") | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   defp create_user(conn, %{"user" => user_params}, invite \\ nil) do | ||||
|     case Accounts.register_user(user_params) do | ||||
|   defp create_user(conn, %{"user" => user_params}, invite_token \\ nil) do | ||||
|     case Accounts.register_user(user_params, invite_token) do | ||||
|       {:ok, user} -> | ||||
|         unless invite |> is_nil() do | ||||
|           invite |> Invites.use_invite!() | ||||
|         end | ||||
|  | ||||
|         Accounts.deliver_user_confirmation_instructions( | ||||
|           user, | ||||
|           &Routes.user_confirmation_url(conn, :confirm, &1) | ||||
|           fn token -> url(MemexWeb.Endpoint, ~p"/users/confirm/#{token}") end | ||||
|         ) | ||||
|  | ||||
|         conn | ||||
|         |> put_flash(:info, dgettext("prompts", "Please check your email to verify your account")) | ||||
|         |> redirect(to: Routes.user_session_path(Endpoint, :new)) | ||||
|         |> put_flash(:info, dgettext("prompts", "please check your email to verify your account")) | ||||
|         |> redirect(to: ~p"/users/log_in") | ||||
|  | ||||
|       {:error, %Ecto.Changeset{} = changeset} -> | ||||
|         conn |> render("new.html", changeset: changeset, invite: invite) | ||||
|       {:error, :invalid_token} -> | ||||
|         conn | ||||
|         |> put_flash(:error, dgettext("errors", "sorry, this invite was not found or expired")) | ||||
|         |> redirect(to: ~p"/") | ||||
|  | ||||
|       {:error, %Changeset{} = changeset} -> | ||||
|         conn |> render(:new, changeset: changeset, invite_token: invite_token) | ||||
|     end | ||||
|   end | ||||
| end | ||||
|   | ||||
							
								
								
									
										5
									
								
								lib/memex_web/controllers/user_registration_html.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								lib/memex_web/controllers/user_registration_html.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| defmodule MemexWeb.UserRegistrationHTML do | ||||
|   use MemexWeb, :html | ||||
|  | ||||
|   embed_templates "user_registration_html/*" | ||||
| end | ||||
| @@ -1,4 +1,4 @@ | ||||
| <div class="mx-auto mb-8 max-w-2xl flex flex-col justify-center items-center space-y-4"> | ||||
| <div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4"> | ||||
|   <h1 class="title text-primary-400 text-xl"> | ||||
|     <%= dgettext("actions", "register") %> | ||||
|   </h1> | ||||
| @@ -6,26 +6,22 @@ | ||||
|   <.form | ||||
|     :let={f} | ||||
|     for={@changeset} | ||||
|     action={Routes.user_registration_path(@conn, :create)} | ||||
|     action={~p"/users/register"} | ||||
|     class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" | ||||
|   > | ||||
|     <%= if @changeset.action && not @changeset.valid? do %> | ||||
|       <div class="alert alert-danger col-span-3"> | ||||
|         <p> | ||||
|           <%= dgettext("errors", "Oops, something went wrong! Please check the errors below.") %> | ||||
|         </p> | ||||
|       </div> | ||||
|     <p :if={@changeset.action && not @changeset.valid?()} class="alert alert-danger col-span-3"> | ||||
|       <%= dgettext("errors", "oops, something went wrong! please check the errors below.") %> | ||||
|     </p> | ||||
| 
 | ||||
|     <%= if @invite_token do %> | ||||
|       <%= hidden_input(f, :invite_token, value: @invite_token) %> | ||||
|     <% end %> | ||||
| 
 | ||||
|     <%= if @invite do %> | ||||
|       <%= hidden_input(f, :invite_token, value: @invite.token) %> | ||||
|     <% end %> | ||||
| 
 | ||||
|     <%= label(f, :email, class: "title text-lg text-primary-400") %> | ||||
|     <%= label(f, :email, gettext("email"), class: "title text-lg text-primary-400") %> | ||||
|     <%= email_input(f, :email, required: true, class: "input input-primary col-span-2") %> | ||||
|     <%= error_tag(f, :email, "col-span-3") %> | ||||
| 
 | ||||
|     <%= label(f, :password, class: "title text-lg text-primary-400") %> | ||||
|     <%= label(f, :password, gettext("password"), class: "title text-lg text-primary-400") %> | ||||
|     <%= password_input(f, :password, required: true, class: "input input-primary col-span-2") %> | ||||
|     <%= error_tag(f, :password, "col-span-3") %> | ||||
| 
 | ||||
| @@ -44,10 +40,10 @@ | ||||
|   <hr class="hr" /> | ||||
| 
 | ||||
|   <div class="flex flex-row justify-center items-center space-x-4"> | ||||
|     <.link href={Routes.user_session_path(@conn, :new)} class="btn btn-primary"> | ||||
|     <.link href={~p"/users/log_in"} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "log in") %> | ||||
|     </.link> | ||||
|     <.link href={Routes.user_reset_password_path(@conn, :new)} class="btn btn-primary"> | ||||
|     <.link href={~p"/users/reset_password"} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "forgot your password?") %> | ||||
|     </.link> | ||||
|   </div> | ||||
| @@ -6,14 +6,14 @@ defmodule MemexWeb.UserResetPasswordController do | ||||
|   plug :get_user_by_reset_password_token when action in [:edit, :update] | ||||
|  | ||||
|   def new(conn, _params) do | ||||
|     render(conn, "new.html", page_title: gettext("forgot your password?")) | ||||
|     render(conn, :new, page_title: gettext("forgot your password?")) | ||||
|   end | ||||
|  | ||||
|   def create(conn, %{"user" => %{"email" => email}}) do | ||||
|     if user = Accounts.get_user_by_email(email) do | ||||
|       Accounts.deliver_user_reset_password_instructions( | ||||
|         user, | ||||
|         &Routes.user_reset_password_url(conn, :edit, &1) | ||||
|         fn token -> url(MemexWeb.Endpoint, ~p"/users/reset_password/#{token}") end | ||||
|       ) | ||||
|     end | ||||
|  | ||||
| @@ -23,17 +23,16 @@ defmodule MemexWeb.UserResetPasswordController do | ||||
|       :info, | ||||
|       dgettext( | ||||
|         "prompts", | ||||
|         "If your email is in our system, you will receive instructions to " <> | ||||
|           "reset your password shortly." | ||||
|         "if your email is in our system, you will receive instructions to reset your password shortly." | ||||
|       ) | ||||
|     ) | ||||
|     |> redirect(to: "/") | ||||
|     |> redirect(to: ~p"/") | ||||
|   end | ||||
|  | ||||
|   def edit(conn, _params) do | ||||
|     render(conn, "edit.html", | ||||
|     render(conn, :edit, | ||||
|       changeset: Accounts.change_user_password(conn.assigns.user), | ||||
|       page_title: gettext("Reset your password") | ||||
|       page_title: gettext("reset your password") | ||||
|     ) | ||||
|   end | ||||
|  | ||||
| @@ -43,11 +42,11 @@ defmodule MemexWeb.UserResetPasswordController do | ||||
|     case Accounts.reset_user_password(conn.assigns.user, user_params) do | ||||
|       {:ok, _} -> | ||||
|         conn | ||||
|         |> put_flash(:info, dgettext("prompts", "Password reset successfully.")) | ||||
|         |> redirect(to: Routes.user_session_path(conn, :new)) | ||||
|         |> put_flash(:info, dgettext("prompts", "password reset successfully.")) | ||||
|         |> redirect(to: ~p"/users/log_in") | ||||
|  | ||||
|       {:error, changeset} -> | ||||
|         render(conn, "edit.html", changeset: changeset) | ||||
|         render(conn, :edit, changeset: changeset) | ||||
|     end | ||||
|   end | ||||
|  | ||||
| @@ -60,9 +59,9 @@ defmodule MemexWeb.UserResetPasswordController do | ||||
|       conn | ||||
|       |> put_flash( | ||||
|         :error, | ||||
|         dgettext("errors", "Reset password link is invalid or it has expired.") | ||||
|         dgettext("errors", "reset password link is invalid or it has expired.") | ||||
|       ) | ||||
|       |> redirect(to: "/") | ||||
|       |> redirect(to: ~p"/") | ||||
|       |> halt() | ||||
|     end | ||||
|   end | ||||
|   | ||||
							
								
								
									
										6
									
								
								lib/memex_web/controllers/user_reset_password_html.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								lib/memex_web/controllers/user_reset_password_html.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| defmodule MemexWeb.UserResetPasswordHTML do | ||||
|   use MemexWeb, :html | ||||
|   alias Memex.Accounts | ||||
|  | ||||
|   embed_templates "user_reset_password_html/*" | ||||
| end | ||||
| @@ -0,0 +1,44 @@ | ||||
| <div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4"> | ||||
|   <h1 class="title text-primary-400 text-xl"> | ||||
|     <%= dgettext("actions", "reset password") %> | ||||
|   </h1> | ||||
|  | ||||
|   <.form | ||||
|     :let={f} | ||||
|     for={@changeset} | ||||
|     action={~p"/users/reset_password/#{@token}"} | ||||
|     class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" | ||||
|   > | ||||
|     <p :if={@changeset.action && not @changeset.valid?()} class="alert alert-danger col-span-3"> | ||||
|       <%= dgettext("errors", "oops, something went wrong! please check the errors below.") %> | ||||
|     </p> | ||||
|  | ||||
|     <%= label(f, :password, gettext("new password"), class: "title text-lg text-primary-400") %> | ||||
|     <%= password_input(f, :password, required: true, class: "input input-primary col-span-2") %> | ||||
|     <%= error_tag(f, :password, "col-span-3") %> | ||||
|  | ||||
|     <%= label(f, :password_confirmation, gettext("confirm new password"), | ||||
|       class: "title text-lg text-primary-400" | ||||
|     ) %> | ||||
|     <%= password_input(f, :password_confirmation, | ||||
|       required: true, | ||||
|       class: "input input-primary col-span-2" | ||||
|     ) %> | ||||
|     <%= error_tag(f, :password_confirmation, "col-span-3") %> | ||||
|  | ||||
|     <%= submit(dgettext("actions", "reset password"), | ||||
|       class: "mx-auto btn btn-primary col-span-3" | ||||
|     ) %> | ||||
|   </.form> | ||||
|  | ||||
|   <hr class="hr" /> | ||||
|  | ||||
|   <div class="flex flex-row justify-center items-center space-x-4"> | ||||
|     <.link :if={Accounts.allow_registration?()} href={~p"/users/register"} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "register") %> | ||||
|     </.link> | ||||
|     <.link href={~p"/users/log_in"} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "log in") %> | ||||
|     </.link> | ||||
|   </div> | ||||
| </div> | ||||
| @@ -1,15 +1,16 @@ | ||||
| <div class="mx-auto mb-8 max-w-2xl flex flex-col justify-center items-center space-y-4"> | ||||
| <div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4"> | ||||
|   <h1 class="title text-primary-400 text-xl"> | ||||
|     <%= dgettext("actions", "forgot your password?") %> | ||||
|   </h1> | ||||
| 
 | ||||
|   <.form | ||||
|     :let={f} | ||||
|     for={:user} | ||||
|     action={Routes.user_reset_password_path(@conn, :create)} | ||||
|     for={%{}} | ||||
|     as={:user} | ||||
|     action={~p"/users/reset_password"} | ||||
|     class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" | ||||
|   > | ||||
|     <%= label(f, :email, class: "title text-lg text-primary-400") %> | ||||
|     <%= label(f, :email, gettext("email"), class: "title text-lg text-primary-400") %> | ||||
|     <%= email_input(f, :email, required: true, class: "input input-primary col-span-2") %> | ||||
| 
 | ||||
|     <%= submit(dgettext("actions", "send instructions to reset password"), | ||||
| @@ -20,12 +21,10 @@ | ||||
|   <hr class="hr" /> | ||||
| 
 | ||||
|   <div class="flex flex-row justify-center items-center space-x-4"> | ||||
|     <%= if Accounts.allow_registration?() do %> | ||||
|       <.link href={Routes.user_registration_path(@conn, :new)} class="btn btn-primary"> | ||||
|         <%= dgettext("actions", "register") %> | ||||
|       </.link> | ||||
|     <% end %> | ||||
|     <.link href={Routes.user_session_path(@conn, :new)} class="btn btn-primary"> | ||||
|     <.link :if={Accounts.allow_registration?()} href={~p"/users/register"} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "register") %> | ||||
|     </.link> | ||||
|     <.link href={~p"/users/log_in"} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "log in") %> | ||||
|     </.link> | ||||
|   </div> | ||||
| @@ -5,7 +5,7 @@ defmodule MemexWeb.UserSessionController do | ||||
|   alias MemexWeb.UserAuth | ||||
|  | ||||
|   def new(conn, _params) do | ||||
|     render(conn, "new.html", error_message: nil, page_title: gettext("log in")) | ||||
|     render(conn, :new, error_message: nil, page_title: gettext("log in")) | ||||
|   end | ||||
|  | ||||
|   def create(conn, %{"user" => user_params}) do | ||||
| @@ -14,7 +14,7 @@ defmodule MemexWeb.UserSessionController do | ||||
|     if user = Accounts.get_user_by_email_and_password(email, password) do | ||||
|       UserAuth.log_in_user(conn, user, user_params) | ||||
|     else | ||||
|       render(conn, "new.html", error_message: dgettext("errors", "Invalid email or password")) | ||||
|       render(conn, :new, error_message: dgettext("errors", "invalid email or password")) | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   | ||||
							
								
								
									
										6
									
								
								lib/memex_web/controllers/user_session_html.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								lib/memex_web/controllers/user_session_html.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,6 @@ | ||||
| defmodule MemexWeb.UserSessionHTML do | ||||
|   use MemexWeb, :html | ||||
|   alias Memex.Accounts | ||||
|  | ||||
|   embed_templates "user_session_html/*" | ||||
| end | ||||
| @@ -1,4 +1,4 @@ | ||||
| <div class="mx-auto mb-8 max-w-2xl flex flex-col justify-center items-center space-y-4"> | ||||
| <div class="mx-auto pb-8 max-w-2xl flex flex-col justify-center items-center space-y-4"> | ||||
|   <h1 class="title text-primary-400 text-xl"> | ||||
|     <%= dgettext("actions", "log in") %> | ||||
|   </h1> | ||||
| @@ -6,25 +6,21 @@ | ||||
|   <.form | ||||
|     :let={f} | ||||
|     for={@conn} | ||||
|     action={Routes.user_session_path(@conn, :create)} | ||||
|     as="user" | ||||
|     action={~p"/users/log_in"} | ||||
|     as={:user} | ||||
|     class="flex flex-col space-y-4 sm:space-y-0 sm:grid sm:grid-cols-3 sm:gap-4 justify-center items-center" | ||||
|   > | ||||
|     <%= if @error_message do %> | ||||
|       <div class="alert alert-danger col-span-3"> | ||||
|         <p> | ||||
|           <%= @error_message %> | ||||
|         </p> | ||||
|       </div> | ||||
|     <% end %> | ||||
|     <p :if={@error_message} class="alert alert-danger col-span-3"> | ||||
|       <%= @error_message %> | ||||
|     </p> | ||||
| 
 | ||||
|     <%= label(f, :email, class: "title text-lg text-primary-400") %> | ||||
|     <%= label(f, :email, gettext("email"), class: "title text-lg text-primary-400") %> | ||||
|     <%= email_input(f, :email, required: true, class: "input input-primary col-span-2") %> | ||||
| 
 | ||||
|     <%= label(f, :password, class: "title text-lg text-primary-400") %> | ||||
|     <%= label(f, :password, gettext("password"), class: "title text-lg text-primary-400") %> | ||||
|     <%= password_input(f, :password, required: true, class: "input input-primary col-span-2") %> | ||||
| 
 | ||||
|     <%= label(f, :remember_me, gettext("Keep me logged in for 60 days"), | ||||
|     <%= label(f, :remember_me, gettext("keep me logged in for 60 days"), | ||||
|       class: "title text-lg text-primary-400" | ||||
|     ) %> | ||||
|     <%= checkbox(f, :remember_me, class: "checkbox col-span-2") %> | ||||
| @@ -35,12 +31,10 @@ | ||||
|   <hr class="hr" /> | ||||
| 
 | ||||
|   <div class="flex flex-row justify-center items-center space-x-4"> | ||||
|     <%= if Accounts.allow_registration?() do %> | ||||
|       <.link href={Routes.user_registration_path(@conn, :new)} class="btn btn-primary"> | ||||
|         <%= dgettext("actions", "register") %> | ||||
|       </.link> | ||||
|     <% end %> | ||||
|     <.link href={Routes.user_reset_password_path(@conn, :new)} class="btn btn-primary"> | ||||
|     <.link :if={Accounts.allow_registration?()} href={~p"/users/register"} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "register") %> | ||||
|     </.link> | ||||
|     <.link href={~p"/users/reset_password"} class="btn btn-primary"> | ||||
|       <%= dgettext("actions", "forgot your password?") %> | ||||
|     </.link> | ||||
|   </div> | ||||
| @@ -2,12 +2,12 @@ defmodule MemexWeb.UserSettingsController do | ||||
|   use MemexWeb, :controller | ||||
|   import MemexWeb.Gettext | ||||
|   alias Memex.Accounts | ||||
|   alias MemexWeb.{HomeLive, UserAuth} | ||||
|   alias MemexWeb.UserAuth | ||||
|  | ||||
|   plug :assign_email_and_password_changesets | ||||
|  | ||||
|   def edit(conn, _params) do | ||||
|     render(conn, "edit.html", page_title: gettext("Settings")) | ||||
|     render(conn, :edit, page_title: gettext("settings")) | ||||
|   end | ||||
|  | ||||
|   def update(%{assigns: %{current_user: user}} = conn, %{ | ||||
| @@ -20,7 +20,7 @@ defmodule MemexWeb.UserSettingsController do | ||||
|         Accounts.deliver_update_email_instructions( | ||||
|           applied_user, | ||||
|           user.email, | ||||
|           &Routes.user_settings_url(conn, :confirm_email, &1) | ||||
|           fn token -> url(MemexWeb.Endpoint, ~p"/users/settings/confirm_email/#{token}") end | ||||
|         ) | ||||
|  | ||||
|         conn | ||||
| @@ -28,13 +28,13 @@ defmodule MemexWeb.UserSettingsController do | ||||
|           :info, | ||||
|           dgettext( | ||||
|             "prompts", | ||||
|             "A link to confirm your email change has been sent to the new address." | ||||
|             "a link to confirm your email change has been sent to the new address." | ||||
|           ) | ||||
|         ) | ||||
|         |> redirect(to: Routes.user_settings_path(conn, :edit)) | ||||
|         |> redirect(to: ~p"/users/settings") | ||||
|  | ||||
|       {:error, changeset} -> | ||||
|         conn |> render("edit.html", email_changeset: changeset) | ||||
|         conn |> render(:edit, email_changeset: changeset) | ||||
|     end | ||||
|   end | ||||
|  | ||||
| @@ -46,12 +46,12 @@ defmodule MemexWeb.UserSettingsController do | ||||
|     case Accounts.update_user_password(user, password, user_params) do | ||||
|       {:ok, user} -> | ||||
|         conn | ||||
|         |> put_flash(:info, dgettext("prompts", "Password updated successfully.")) | ||||
|         |> put_session(:user_return_to, Routes.user_settings_path(conn, :edit)) | ||||
|         |> put_flash(:info, dgettext("prompts", "password updated successfully.")) | ||||
|         |> put_session(:user_return_to, ~p"/users/settings") | ||||
|         |> UserAuth.log_in_user(user) | ||||
|  | ||||
|       {:error, changeset} -> | ||||
|         conn |> render("edit.html", password_changeset: changeset) | ||||
|         conn |> render(:edit, password_changeset: changeset) | ||||
|     end | ||||
|   end | ||||
|  | ||||
| @@ -63,10 +63,10 @@ defmodule MemexWeb.UserSettingsController do | ||||
|       {:ok, _user} -> | ||||
|         conn | ||||
|         |> put_flash(:info, dgettext("prompts", "language updated successfully.")) | ||||
|         |> redirect(to: Routes.user_settings_path(conn, :edit)) | ||||
|         |> redirect(to: ~p"/users/settings") | ||||
|  | ||||
|       {:error, changeset} -> | ||||
|         conn |> render("edit.html", locale_changeset: changeset) | ||||
|         conn |> render(:edit, locale_changeset: changeset) | ||||
|     end | ||||
|   end | ||||
|  | ||||
| @@ -74,16 +74,16 @@ defmodule MemexWeb.UserSettingsController do | ||||
|     case Accounts.update_user_email(user, token) do | ||||
|       :ok -> | ||||
|         conn | ||||
|         |> put_flash(:info, dgettext("prompts", "Email changed successfully.")) | ||||
|         |> redirect(to: Routes.user_settings_path(conn, :edit)) | ||||
|         |> put_flash(:info, dgettext("prompts", "email changed successfully.")) | ||||
|         |> redirect(to: ~p"/users/settings") | ||||
|  | ||||
|       :error -> | ||||
|         conn | ||||
|         |> put_flash( | ||||
|           :error, | ||||
|           dgettext("errors", "Email change link is invalid or it has expired.") | ||||
|           dgettext("errors", "email change link is invalid or it has expired.") | ||||
|         ) | ||||
|         |> redirect(to: Routes.user_settings_path(conn, :edit)) | ||||
|         |> redirect(to: ~p"/users/settings") | ||||
|     end | ||||
|   end | ||||
|  | ||||
| @@ -92,12 +92,12 @@ defmodule MemexWeb.UserSettingsController do | ||||
|       current_user |> Accounts.delete_user!(current_user) | ||||
|  | ||||
|       conn | ||||
|       |> put_flash(:error, dgettext("prompts", "Your account has been deleted")) | ||||
|       |> redirect(to: Routes.live_path(conn, HomeLive)) | ||||
|       |> put_flash(:error, dgettext("prompts", "your account has been deleted")) | ||||
|       |> redirect(to: ~p"/") | ||||
|     else | ||||
|       conn | ||||
|       |> put_flash(:error, dgettext("errors", "Unable to delete user")) | ||||
|       |> redirect(to: Routes.user_settings_path(conn, :edit)) | ||||
|       |> put_flash(:error, dgettext("errors", "unable to delete user")) | ||||
|       |> redirect(to: ~p"/users/settings") | ||||
|     end | ||||
|   end | ||||
|  | ||||
|   | ||||
							
								
								
									
										5
									
								
								lib/memex_web/controllers/user_settings_html.ex
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								lib/memex_web/controllers/user_settings_html.ex
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | ||||
| defmodule MemexWeb.UserSettingsHTML do | ||||
|   use MemexWeb, :html | ||||
|  | ||||
|   embed_templates "user_settings_html/*" | ||||
| end | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user